@os-eco/overstory-cli 0.7.8 → 0.7.9

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 CHANGED
@@ -6,7 +6,7 @@ Multi-agent orchestration for AI coding agents.
6
6
  [![CI](https://github.com/jayminwest/overstory/actions/workflows/ci.yml/badge.svg)](https://github.com/jayminwest/overstory/actions/workflows/ci.yml)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
8
 
9
- Overstory turns a single coding session into a multi-agent team by spawning worker agents in git worktrees via tmux, coordinating them through a custom SQLite mail system, and merging their work back with tiered conflict resolution. A pluggable `AgentRuntime` interface lets you swap between runtimes — Claude Code, [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), or your own adapter.
9
+ Overstory turns a single coding session into a multi-agent team by spawning worker agents in git worktrees via tmux, coordinating them through a custom SQLite mail system, and merging their work back with tiered conflict resolution. A pluggable `AgentRuntime` interface lets you swap between runtimes — Claude Code, [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or your own adapter.
10
10
 
11
11
  > **Warning: Agent swarms are not a universal solution.** Do not deploy Overstory without understanding the risks of multi-agent orchestration — compounding error rates, cost amplification, debugging complexity, and merge conflicts are the normal case, not edge cases. Read [STEELMAN.md](STEELMAN.md) for a full risk analysis and the [Agentic Engineering Book](https://github.com/jayminwest/agentic-engineering-book) ([web version](https://jayminwest.com/agentic-engineering-book)) before using this tool in production.
12
12
 
@@ -18,6 +18,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
18
18
  - [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) (`pi` CLI)
19
19
  - [GitHub Copilot](https://github.com/features/copilot) (`copilot` CLI)
20
20
  - [Codex](https://github.com/openai/codex) (`codex` CLI)
21
+ - [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` CLI)
21
22
 
22
23
  ```bash
23
24
  bun install -g @os-eco/overstory-cli
@@ -175,6 +176,7 @@ Overstory is runtime-agnostic. The `AgentRuntime` interface (`src/runtimes/types
175
176
  | Pi | `pi` | `.pi/extensions/` guard extension | Active development |
176
177
  | Copilot | `copilot` | (none — `--allow-all-tools`) | Active development |
177
178
  | Codex | `codex` | OS-level sandbox (Seatbelt/Landlock) | Active development |
179
+ | Gemini | `gemini` | `--sandbox` flag | Active development |
178
180
 
179
181
  ## How It Works
180
182
 
@@ -271,7 +273,7 @@ overstory/
271
273
  metrics/ SQLite metrics + pricing + transcript parsing
272
274
  doctor/ Health check modules (11 checks)
273
275
  insights/ Session insight analyzer for auto-expertise
274
- runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex)
276
+ runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex, Gemini)
275
277
  tracker/ Pluggable task tracker (beads + seeds backends)
276
278
  mulch/ mulch client (programmatic API + CLI wrapper)
277
279
  e2e/ End-to-end lifecycle tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -5,7 +5,12 @@ import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
6
  import { cleanupTempDir } from "../test-helpers.ts";
7
7
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
8
- import { createManifestLoader, resolveModel, resolveProviderEnv } from "./manifest.ts";
8
+ import {
9
+ createManifestLoader,
10
+ expandAliasFromEnv,
11
+ resolveModel,
12
+ resolveProviderEnv,
13
+ } from "./manifest.ts";
9
14
 
10
15
  const VALID_MANIFEST = {
11
16
  version: "1.0",
@@ -673,6 +678,168 @@ describe("resolveModel", () => {
673
678
  });
674
679
  });
675
680
 
681
+ describe("expandAliasFromEnv", () => {
682
+ test("returns expanded model ID when env var is set", () => {
683
+ expect(
684
+ expandAliasFromEnv("haiku", {
685
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
686
+ }),
687
+ ).toBe("us.anthropic.claude-3-5-haiku-20241022-v1:0");
688
+ });
689
+
690
+ test("returns alias unchanged when env var is unset", () => {
691
+ expect(expandAliasFromEnv("haiku", {})).toBe("haiku");
692
+ });
693
+
694
+ test("expands all three aliases via their env vars", () => {
695
+ const env = {
696
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "bedrock-haiku-id",
697
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "bedrock-sonnet-id",
698
+ ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-id",
699
+ };
700
+ expect(expandAliasFromEnv("haiku", env)).toBe("bedrock-haiku-id");
701
+ expect(expandAliasFromEnv("sonnet", env)).toBe("bedrock-sonnet-id");
702
+ expect(expandAliasFromEnv("opus", env)).toBe("bedrock-opus-id");
703
+ });
704
+
705
+ test("trims whitespace from env var value", () => {
706
+ expect(
707
+ expandAliasFromEnv("sonnet", {
708
+ ANTHROPIC_DEFAULT_SONNET_MODEL: " bedrock-sonnet-id ",
709
+ }),
710
+ ).toBe("bedrock-sonnet-id");
711
+ });
712
+
713
+ test("returns alias when env var is empty string", () => {
714
+ expect(expandAliasFromEnv("sonnet", { ANTHROPIC_DEFAULT_SONNET_MODEL: "" })).toBe("sonnet");
715
+ });
716
+
717
+ test("returns alias when env var is whitespace only", () => {
718
+ expect(expandAliasFromEnv("sonnet", { ANTHROPIC_DEFAULT_SONNET_MODEL: " " })).toBe("sonnet");
719
+ });
720
+
721
+ test("returns unknown alias unchanged", () => {
722
+ expect(expandAliasFromEnv("gpt-4", {})).toBe("gpt-4");
723
+ });
724
+ });
725
+
726
+ describe("resolveModel env var expansion", () => {
727
+ const baseManifest: AgentManifest = {
728
+ version: "1.0",
729
+ agents: {
730
+ scout: {
731
+ file: "scout.md",
732
+ model: "haiku",
733
+ tools: ["Read"],
734
+ capabilities: ["explore"],
735
+ canSpawn: false,
736
+ constraints: [],
737
+ },
738
+ builder: {
739
+ file: "builder.md",
740
+ model: "sonnet",
741
+ tools: ["Read", "Write"],
742
+ capabilities: ["implement"],
743
+ canSpawn: false,
744
+ constraints: [],
745
+ },
746
+ },
747
+ capabilityIndex: { explore: ["scout"], implement: ["builder"] },
748
+ };
749
+
750
+ function makeConfig(models: OverstoryConfig["models"] = {}): OverstoryConfig {
751
+ return {
752
+ project: { name: "test", root: "/tmp/test", canonicalBranch: "main" },
753
+ agents: {
754
+ manifestPath: ".overstory/agent-manifest.json",
755
+ baseDir: ".overstory/agent-defs",
756
+ maxConcurrent: 5,
757
+ staggerDelayMs: 1000,
758
+ maxDepth: 2,
759
+ maxSessionsPerRun: 0,
760
+ maxAgentsPerLead: 5,
761
+ },
762
+ worktrees: { baseDir: ".overstory/worktrees" },
763
+ taskTracker: { backend: "auto", enabled: false },
764
+ mulch: { enabled: false, domains: [], primeFormat: "markdown" },
765
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
766
+ providers: { anthropic: { type: "native" } },
767
+ watchdog: {
768
+ tier0Enabled: false,
769
+ tier0IntervalMs: 30000,
770
+ tier1Enabled: false,
771
+ tier2Enabled: false,
772
+ staleThresholdMs: 300000,
773
+ zombieThresholdMs: 600000,
774
+ nudgeIntervalMs: 60000,
775
+ },
776
+ models,
777
+ logging: { verbose: false, redactSecrets: true },
778
+ };
779
+ }
780
+
781
+ test("expands alias when env var is set", () => {
782
+ const saved = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
783
+ process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "us.anthropic.claude-3-5-haiku-20241022-v1:0";
784
+ try {
785
+ const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
786
+ expect(result).toEqual({ model: "us.anthropic.claude-3-5-haiku-20241022-v1:0" });
787
+ } finally {
788
+ if (saved === undefined) {
789
+ delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
790
+ } else {
791
+ process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = saved;
792
+ }
793
+ }
794
+ });
795
+
796
+ test("passes alias through when env var is unset", () => {
797
+ const saved = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
798
+ delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
799
+ try {
800
+ const result = resolveModel(makeConfig(), baseManifest, "scout", "sonnet");
801
+ expect(result).toEqual({ model: "haiku" });
802
+ } finally {
803
+ if (saved !== undefined) {
804
+ process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = saved;
805
+ }
806
+ }
807
+ });
808
+
809
+ test("config override to full model ID is not affected by env vars", () => {
810
+ const saved = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
811
+ process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "bedrock-sonnet";
812
+ try {
813
+ // Config overrides to a direct model string (not an alias)
814
+ const config = makeConfig({ builder: "claude-3-5-sonnet-20241022" });
815
+ const result = resolveModel(config, baseManifest, "builder", "haiku");
816
+ expect(result).toEqual({ model: "claude-3-5-sonnet-20241022" });
817
+ } finally {
818
+ if (saved === undefined) {
819
+ delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
820
+ } else {
821
+ process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = saved;
822
+ }
823
+ }
824
+ });
825
+
826
+ test("config override to alias also expands via env var", () => {
827
+ const saved = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
828
+ process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "bedrock-opus-id";
829
+ try {
830
+ const config = makeConfig({ scout: "opus" });
831
+ const result = resolveModel(config, baseManifest, "scout", "haiku");
832
+ expect(result).toEqual({ model: "bedrock-opus-id" });
833
+ } finally {
834
+ if (saved === undefined) {
835
+ delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
836
+ } else {
837
+ process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = saved;
838
+ }
839
+ }
840
+ });
841
+ });
842
+
676
843
  describe("resolveProviderEnv", () => {
677
844
  test("returns null for unknown provider", () => {
678
845
  const result = resolveProviderEnv("unknown", "some/model", {});
@@ -34,6 +34,27 @@ interface RawManifest {
34
34
 
35
35
  const MODEL_ALIASES = new Set(["sonnet", "opus", "haiku"]);
36
36
 
37
+ // Env var mapping: alias → ANTHROPIC_DEFAULT_{ALIAS}_MODEL
38
+ const ALIAS_ENV_VARS: Record<string, string> = {
39
+ haiku: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
40
+ sonnet: "ANTHROPIC_DEFAULT_SONNET_MODEL",
41
+ opus: "ANTHROPIC_DEFAULT_OPUS_MODEL",
42
+ };
43
+
44
+ /**
45
+ * Expand a model alias via its corresponding ANTHROPIC_DEFAULT_{ALIAS}_MODEL env var.
46
+ * Returns the env var value if set, otherwise the original alias.
47
+ */
48
+ export function expandAliasFromEnv(
49
+ alias: string,
50
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
51
+ ): string {
52
+ const envVar = ALIAS_ENV_VARS[alias];
53
+ if (!envVar) return alias;
54
+ const value = env[envVar];
55
+ return value?.trim() || alias;
56
+ }
57
+
37
58
  /**
38
59
  * Validate that a raw parsed object conforms to the AgentDefinition shape.
39
60
  * Returns a list of error messages for any violations.
@@ -333,9 +354,9 @@ export function resolveModel(
333
354
  const configModel = config.models[role];
334
355
  const rawModel = configModel ?? manifest.agents[role]?.model ?? fallback;
335
356
 
336
- // Simple alias — no provider env needed
357
+ // Simple alias — expand via env var if set (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL)
337
358
  if (MODEL_ALIASES.has(rawModel)) {
338
- return { model: rawModel };
359
+ return { model: expandAliasFromEnv(rawModel) };
339
360
  }
340
361
 
341
362
  // Provider-prefixed: split on first "/" to get provider name and model ID
@@ -34,6 +34,7 @@ export interface DiscoveredAgent {
34
34
  const KNOWN_INSTRUCTION_PATHS = [
35
35
  join(".claude", "CLAUDE.md"), // Claude Code, Pi
36
36
  "AGENTS.md", // Codex (future)
37
+ "GEMINI.md", // Gemini CLI
37
38
  ];
38
39
 
39
40
  /**
@@ -166,6 +166,7 @@ describe("initCommand: .overstory/.gitignore", () => {
166
166
  expect(content).toContain("!hooks.json\n");
167
167
  expect(content).toContain("!groups.json\n");
168
168
  expect(content).toContain("!agent-defs/\n");
169
+ expect(content).toContain("!agent-defs/**\n");
169
170
 
170
171
  // Verify it matches the exported constant
171
172
  expect(content).toBe(OVERSTORY_GITIGNORE);
@@ -588,6 +588,7 @@ export const OVERSTORY_GITIGNORE = `# Wildcard+whitelist: ignore everything, whi
588
588
  !hooks.json
589
589
  !groups.json
590
590
  !agent-defs/
591
+ !agent-defs/**
591
592
  !README.md
592
593
  `;
593
594
 
@@ -366,6 +366,7 @@ recentTasks: []
366
366
  !hooks.json
367
367
  !groups.json
368
368
  !agent-defs/
369
+ !agent-defs/**
369
370
  !README.md
370
371
  `;
371
372
 
@@ -114,6 +114,7 @@ describe("checkStructure", () => {
114
114
  !hooks.json
115
115
  !groups.json
116
116
  !agent-defs/
117
+ !agent-defs/**
117
118
  `,
118
119
  );
119
120
 
@@ -111,6 +111,7 @@ export const checkStructure: DoctorCheckFn = async (
111
111
  "!hooks.json",
112
112
  "!groups.json",
113
113
  "!agent-defs/",
114
+ "!agent-defs/**",
114
115
  ];
115
116
 
116
117
  try {
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { OverstoryError, WorktreeError } from "./errors.ts";
45
45
  import { jsonError } from "./json.ts";
46
46
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
47
 
48
- export const VERSION = "0.7.8";
48
+ export const VERSION = "0.7.9";
49
49
 
50
50
  const rawArgs = process.argv.slice(2);
51
51
 
@@ -0,0 +1,537 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
6
+ import type { ResolvedModel } from "../types.ts";
7
+ import { GeminiRuntime } from "./gemini.ts";
8
+ import type { SpawnOpts } from "./types.ts";
9
+
10
+ describe("GeminiRuntime", () => {
11
+ const runtime = new GeminiRuntime();
12
+
13
+ describe("id and instructionPath", () => {
14
+ test("id is 'gemini'", () => {
15
+ expect(runtime.id).toBe("gemini");
16
+ });
17
+
18
+ test("instructionPath is GEMINI.md", () => {
19
+ expect(runtime.instructionPath).toBe("GEMINI.md");
20
+ });
21
+ });
22
+
23
+ describe("buildSpawnCommand", () => {
24
+ test("bypass permission mode includes --approval-mode yolo", () => {
25
+ const opts: SpawnOpts = {
26
+ model: "gemini-2.5-pro",
27
+ permissionMode: "bypass",
28
+ cwd: "/tmp/worktree",
29
+ env: {},
30
+ };
31
+ const cmd = runtime.buildSpawnCommand(opts);
32
+ expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
33
+ });
34
+
35
+ test("ask permission mode omits approval flag", () => {
36
+ const opts: SpawnOpts = {
37
+ model: "gemini-2.5-flash",
38
+ permissionMode: "ask",
39
+ cwd: "/tmp/worktree",
40
+ env: {},
41
+ };
42
+ const cmd = runtime.buildSpawnCommand(opts);
43
+ expect(cmd).toBe("gemini -m gemini-2.5-flash");
44
+ expect(cmd).not.toContain("--approval-mode");
45
+ expect(cmd).not.toContain("yolo");
46
+ });
47
+
48
+ test("appendSystemPrompt is ignored (gemini has no such flag)", () => {
49
+ const opts: SpawnOpts = {
50
+ model: "gemini-2.5-pro",
51
+ permissionMode: "bypass",
52
+ cwd: "/tmp/worktree",
53
+ env: {},
54
+ appendSystemPrompt: "You are a builder agent.",
55
+ };
56
+ const cmd = runtime.buildSpawnCommand(opts);
57
+ expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
58
+ expect(cmd).not.toContain("append-system-prompt");
59
+ expect(cmd).not.toContain("You are a builder agent");
60
+ });
61
+
62
+ test("appendSystemPromptFile is ignored (gemini has no such flag)", () => {
63
+ const opts: SpawnOpts = {
64
+ model: "gemini-2.5-pro",
65
+ permissionMode: "bypass",
66
+ cwd: "/project",
67
+ env: {},
68
+ appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
69
+ };
70
+ const cmd = runtime.buildSpawnCommand(opts);
71
+ expect(cmd).toBe("gemini -m gemini-2.5-pro --approval-mode yolo");
72
+ expect(cmd).not.toContain("cat");
73
+ expect(cmd).not.toContain("coordinator.md");
74
+ });
75
+
76
+ test("cwd and env are not embedded in command string", () => {
77
+ const opts: SpawnOpts = {
78
+ model: "gemini-2.5-pro",
79
+ permissionMode: "bypass",
80
+ cwd: "/some/specific/path",
81
+ env: { GEMINI_API_KEY: "test-key" },
82
+ };
83
+ const cmd = runtime.buildSpawnCommand(opts);
84
+ expect(cmd).not.toContain("/some/specific/path");
85
+ expect(cmd).not.toContain("test-key");
86
+ expect(cmd).not.toContain("GEMINI_API_KEY");
87
+ });
88
+
89
+ test("model alias is passed through unchanged", () => {
90
+ const opts: SpawnOpts = {
91
+ model: "flash",
92
+ permissionMode: "bypass",
93
+ cwd: "/tmp/worktree",
94
+ env: {},
95
+ };
96
+ const cmd = runtime.buildSpawnCommand(opts);
97
+ expect(cmd).toContain("-m flash");
98
+ });
99
+ });
100
+
101
+ describe("buildPrintCommand", () => {
102
+ test("basic prompt produces gemini -p argv with --yolo", () => {
103
+ const cmd = runtime.buildPrintCommand("Resolve this conflict");
104
+ expect(cmd).toEqual(["gemini", "-p", "Resolve this conflict", "--yolo"]);
105
+ });
106
+
107
+ test("with model override adds -m flag", () => {
108
+ const cmd = runtime.buildPrintCommand("Triage this failure", "gemini-2.5-flash");
109
+ expect(cmd).toEqual([
110
+ "gemini",
111
+ "-p",
112
+ "Triage this failure",
113
+ "--yolo",
114
+ "-m",
115
+ "gemini-2.5-flash",
116
+ ]);
117
+ });
118
+
119
+ test("without model omits -m flag", () => {
120
+ const cmd = runtime.buildPrintCommand("Classify this error");
121
+ expect(cmd).not.toContain("-m");
122
+ });
123
+
124
+ test("prompt with special characters is preserved", () => {
125
+ const prompt = 'Fix the "bug" in file\'s path & run tests';
126
+ const cmd = runtime.buildPrintCommand(prompt);
127
+ expect(cmd[2]).toBe(prompt);
128
+ });
129
+
130
+ test("empty prompt is passed through", () => {
131
+ const cmd = runtime.buildPrintCommand("");
132
+ expect(cmd).toEqual(["gemini", "-p", "", "--yolo"]);
133
+ });
134
+ });
135
+
136
+ describe("deployConfig", () => {
137
+ let tempDir: string;
138
+
139
+ beforeEach(async () => {
140
+ tempDir = await mkdtemp(join(tmpdir(), "ov-gemini-test-"));
141
+ });
142
+
143
+ afterEach(async () => {
144
+ await cleanupTempDir(tempDir);
145
+ });
146
+
147
+ test("writes GEMINI.md to worktree root", async () => {
148
+ await runtime.deployConfig(
149
+ tempDir,
150
+ { content: "# Task\nBuild the feature." },
151
+ { agentName: "test-agent", capability: "builder", worktreePath: tempDir },
152
+ );
153
+
154
+ const file = Bun.file(join(tempDir, "GEMINI.md"));
155
+ expect(await file.exists()).toBe(true);
156
+ expect(await file.text()).toBe("# Task\nBuild the feature.");
157
+ });
158
+
159
+ test("no-op when overlay is undefined", async () => {
160
+ await runtime.deployConfig(tempDir, undefined, {
161
+ agentName: "test-agent",
162
+ capability: "coordinator",
163
+ worktreePath: tempDir,
164
+ });
165
+
166
+ const file = Bun.file(join(tempDir, "GEMINI.md"));
167
+ expect(await file.exists()).toBe(false);
168
+ });
169
+
170
+ test("hooks parameter is unused (no guard deployment)", async () => {
171
+ await runtime.deployConfig(
172
+ tempDir,
173
+ { content: "# Instructions" },
174
+ {
175
+ agentName: "my-builder",
176
+ capability: "builder",
177
+ worktreePath: tempDir,
178
+ qualityGates: [
179
+ { command: "bun test", name: "tests", description: "all tests must pass" },
180
+ ],
181
+ },
182
+ );
183
+
184
+ // Only GEMINI.md should exist — no settings files or guard extensions.
185
+ const geminiFile = Bun.file(join(tempDir, "GEMINI.md"));
186
+ expect(await geminiFile.exists()).toBe(true);
187
+
188
+ // No Claude Code settings file.
189
+ const settingsFile = Bun.file(join(tempDir, ".claude", "settings.local.json"));
190
+ expect(await settingsFile.exists()).toBe(false);
191
+
192
+ // No Pi guard extension.
193
+ const piGuardFile = Bun.file(join(tempDir, ".pi", "extensions", "overstory-guard.ts"));
194
+ expect(await piGuardFile.exists()).toBe(false);
195
+ });
196
+
197
+ test("overwrites existing GEMINI.md", async () => {
198
+ await Bun.write(join(tempDir, "GEMINI.md"), "# Old content");
199
+
200
+ await runtime.deployConfig(
201
+ tempDir,
202
+ { content: "# New content" },
203
+ { agentName: "test-agent", capability: "builder", worktreePath: tempDir },
204
+ );
205
+
206
+ const file = Bun.file(join(tempDir, "GEMINI.md"));
207
+ expect(await file.text()).toBe("# New content");
208
+ });
209
+
210
+ test("creates parent directories if needed", async () => {
211
+ const nestedDir = join(tempDir, "nested", "deep");
212
+
213
+ await runtime.deployConfig(
214
+ nestedDir,
215
+ { content: "# Nested" },
216
+ { agentName: "test-agent", capability: "builder", worktreePath: nestedDir },
217
+ );
218
+
219
+ const file = Bun.file(join(nestedDir, "GEMINI.md"));
220
+ expect(await file.exists()).toBe(true);
221
+ });
222
+ });
223
+
224
+ describe("detectReady", () => {
225
+ test("returns ready when placeholder and gemini branding visible", () => {
226
+ const pane = "✨ Gemini CLI v1.0.0\n\n> Type your message or @path/to/file";
227
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
228
+ });
229
+
230
+ test("returns ready with > prefix and gemini text", () => {
231
+ const pane = "gemini-2.5-pro | model: gemini\n> ";
232
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
233
+ });
234
+
235
+ test("returns ready with ❯ prompt and gemini text", () => {
236
+ const pane = "Gemini CLI\n❯ ";
237
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
238
+ });
239
+
240
+ test("returns loading when no prompt indicator", () => {
241
+ const pane = "Starting Gemini CLI...";
242
+ expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
243
+ });
244
+
245
+ test("returns loading when no gemini branding", () => {
246
+ const pane = "> Type your message or @path/to/file";
247
+ expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
248
+ });
249
+
250
+ test("returns loading for empty pane", () => {
251
+ expect(runtime.detectReady("")).toEqual({ phase: "loading" });
252
+ });
253
+
254
+ test("returns loading during initialization", () => {
255
+ const pane = "Loading model...";
256
+ expect(runtime.detectReady(pane)).toEqual({ phase: "loading" });
257
+ });
258
+
259
+ test("case-insensitive gemini detection", () => {
260
+ const pane = "GEMINI CLI v1.0\n> ready";
261
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
262
+ });
263
+
264
+ test("case-insensitive placeholder detection", () => {
265
+ const pane = "Gemini\nType Your Message here";
266
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
267
+ });
268
+
269
+ test("never returns dialog phase (gemini has no trust dialog)", () => {
270
+ // Try various pane contents — should never get "dialog" phase.
271
+ const panes = ["", "Gemini CLI", "> ready", "Gemini\n> ", "Loading...", "trust this folder"];
272
+ for (const pane of panes) {
273
+ const result = runtime.detectReady(pane);
274
+ expect(result.phase).not.toBe("dialog");
275
+ }
276
+ });
277
+ });
278
+
279
+ describe("requiresBeaconVerification", () => {
280
+ test("not defined — defaults to true (gets resend loop)", () => {
281
+ // GeminiRuntime does not override requiresBeaconVerification.
282
+ // When omitted, the orchestrator defaults to true (resend loop enabled).
283
+ // Verify the method is not present on the instance.
284
+ expect("requiresBeaconVerification" in runtime).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe("parseTranscript", () => {
289
+ let tempDir: string;
290
+
291
+ beforeEach(async () => {
292
+ tempDir = await mkdtemp(join(tmpdir(), "ov-gemini-transcript-"));
293
+ });
294
+
295
+ afterEach(async () => {
296
+ await cleanupTempDir(tempDir);
297
+ });
298
+
299
+ test("returns null for missing file", async () => {
300
+ const result = await runtime.parseTranscript(join(tempDir, "nonexistent.jsonl"));
301
+ expect(result).toBeNull();
302
+ });
303
+
304
+ test("parses init event for model", async () => {
305
+ const transcript = [
306
+ '{"type":"init","timestamp":"2026-01-01T00:00:00Z","session_id":"abc","model":"gemini-2.5-pro"}',
307
+ '{"type":"result","timestamp":"2026-01-01T00:01:00Z","status":"success","stats":{"input_tokens":100,"output_tokens":50,"total_tokens":150,"cached":0,"input":100,"duration_ms":1000,"tool_calls":0}}',
308
+ ].join("\n");
309
+
310
+ const path = join(tempDir, "transcript.jsonl");
311
+ await Bun.write(path, transcript);
312
+
313
+ const result = await runtime.parseTranscript(path);
314
+ expect(result).toEqual({
315
+ inputTokens: 100,
316
+ outputTokens: 50,
317
+ model: "gemini-2.5-pro",
318
+ });
319
+ });
320
+
321
+ test("aggregates token usage from multiple result events", async () => {
322
+ const transcript = [
323
+ '{"type":"init","model":"gemini-2.5-flash"}',
324
+ '{"type":"result","stats":{"input_tokens":200,"output_tokens":100}}',
325
+ '{"type":"result","stats":{"input_tokens":150,"output_tokens":75}}',
326
+ ].join("\n");
327
+
328
+ const path = join(tempDir, "transcript.jsonl");
329
+ await Bun.write(path, transcript);
330
+
331
+ const result = await runtime.parseTranscript(path);
332
+ expect(result).toEqual({
333
+ inputTokens: 350,
334
+ outputTokens: 175,
335
+ model: "gemini-2.5-flash",
336
+ });
337
+ });
338
+
339
+ test("handles transcript with no token usage", async () => {
340
+ const transcript = [
341
+ '{"type":"init","model":"gemini-2.5-pro"}',
342
+ '{"type":"message","role":"user","content":"hello"}',
343
+ '{"type":"message","role":"assistant","content":"hi","delta":true}',
344
+ ].join("\n");
345
+
346
+ const path = join(tempDir, "transcript.jsonl");
347
+ await Bun.write(path, transcript);
348
+
349
+ const result = await runtime.parseTranscript(path);
350
+ expect(result).toEqual({
351
+ inputTokens: 0,
352
+ outputTokens: 0,
353
+ model: "gemini-2.5-pro",
354
+ });
355
+ });
356
+
357
+ test("skips malformed JSON lines", async () => {
358
+ const transcript = [
359
+ '{"type":"init","model":"gemini-2.5-pro"}',
360
+ "this is not json",
361
+ '{"type":"result","stats":{"input_tokens":500,"output_tokens":200}}',
362
+ "{broken json",
363
+ ].join("\n");
364
+
365
+ const path = join(tempDir, "transcript.jsonl");
366
+ await Bun.write(path, transcript);
367
+
368
+ const result = await runtime.parseTranscript(path);
369
+ expect(result).toEqual({
370
+ inputTokens: 500,
371
+ outputTokens: 200,
372
+ model: "gemini-2.5-pro",
373
+ });
374
+ });
375
+
376
+ test("returns empty model when no init event", async () => {
377
+ const transcript = '{"type":"result","stats":{"input_tokens":100,"output_tokens":50}}';
378
+
379
+ const path = join(tempDir, "transcript.jsonl");
380
+ await Bun.write(path, transcript);
381
+
382
+ const result = await runtime.parseTranscript(path);
383
+ expect(result).toEqual({
384
+ inputTokens: 100,
385
+ outputTokens: 50,
386
+ model: "",
387
+ });
388
+ });
389
+
390
+ test("handles empty file", async () => {
391
+ const path = join(tempDir, "empty.jsonl");
392
+ await Bun.write(path, "");
393
+
394
+ const result = await runtime.parseTranscript(path);
395
+ expect(result).toEqual({
396
+ inputTokens: 0,
397
+ outputTokens: 0,
398
+ model: "",
399
+ });
400
+ });
401
+
402
+ test("handles result event with missing stats", async () => {
403
+ const transcript = [
404
+ '{"type":"init","model":"gemini-2.5-pro"}',
405
+ '{"type":"result","status":"error"}',
406
+ ].join("\n");
407
+
408
+ const path = join(tempDir, "transcript.jsonl");
409
+ await Bun.write(path, transcript);
410
+
411
+ const result = await runtime.parseTranscript(path);
412
+ expect(result).toEqual({
413
+ inputTokens: 0,
414
+ outputTokens: 0,
415
+ model: "gemini-2.5-pro",
416
+ });
417
+ });
418
+
419
+ test("handles result event with partial stats", async () => {
420
+ const transcript = [
421
+ '{"type":"init","model":"gemini-2.5-pro"}',
422
+ '{"type":"result","stats":{"input_tokens":300}}',
423
+ ].join("\n");
424
+
425
+ const path = join(tempDir, "transcript.jsonl");
426
+ await Bun.write(path, transcript);
427
+
428
+ const result = await runtime.parseTranscript(path);
429
+ expect(result).toEqual({
430
+ inputTokens: 300,
431
+ outputTokens: 0,
432
+ model: "gemini-2.5-pro",
433
+ });
434
+ });
435
+
436
+ test("fallback model from any event with model field", async () => {
437
+ const transcript = [
438
+ '{"type":"message","role":"assistant","model":"gemini-2.5-pro","content":"hello"}',
439
+ '{"type":"result","stats":{"input_tokens":50,"output_tokens":25}}',
440
+ ].join("\n");
441
+
442
+ const path = join(tempDir, "transcript.jsonl");
443
+ await Bun.write(path, transcript);
444
+
445
+ const result = await runtime.parseTranscript(path);
446
+ expect(result).toEqual({
447
+ inputTokens: 50,
448
+ outputTokens: 25,
449
+ model: "gemini-2.5-pro",
450
+ });
451
+ });
452
+
453
+ test("init event model takes precedence over fallback", async () => {
454
+ const transcript = [
455
+ '{"type":"message","model":"gemini-2.5-flash"}',
456
+ '{"type":"init","model":"gemini-2.5-pro"}',
457
+ '{"type":"result","stats":{"input_tokens":10,"output_tokens":5}}',
458
+ ].join("\n");
459
+
460
+ const path = join(tempDir, "transcript.jsonl");
461
+ await Bun.write(path, transcript);
462
+
463
+ const result = await runtime.parseTranscript(path);
464
+ expect(result).toEqual({
465
+ inputTokens: 10,
466
+ outputTokens: 5,
467
+ model: "gemini-2.5-pro",
468
+ });
469
+ });
470
+
471
+ test("handles trailing newline", async () => {
472
+ const transcript =
473
+ '{"type":"init","model":"gemini-2.5-pro"}\n{"type":"result","stats":{"input_tokens":100,"output_tokens":50}}\n';
474
+
475
+ const path = join(tempDir, "transcript.jsonl");
476
+ await Bun.write(path, transcript);
477
+
478
+ const result = await runtime.parseTranscript(path);
479
+ expect(result).toEqual({
480
+ inputTokens: 100,
481
+ outputTokens: 50,
482
+ model: "gemini-2.5-pro",
483
+ });
484
+ });
485
+
486
+ test("full stream-json transcript with all event types", async () => {
487
+ const transcript = [
488
+ '{"type":"init","timestamp":"2026-03-01T12:00:00Z","session_id":"sess-123","model":"gemini-2.5-pro"}',
489
+ '{"type":"message","timestamp":"2026-03-01T12:00:01Z","role":"user","content":"Fix the bug"}',
490
+ '{"type":"message","timestamp":"2026-03-01T12:00:02Z","role":"assistant","content":"I will","delta":true}',
491
+ '{"type":"tool_use","timestamp":"2026-03-01T12:00:03Z","tool_name":"Read","tool_id":"read-1","parameters":{"file_path":"/src/main.ts"}}',
492
+ '{"type":"tool_result","timestamp":"2026-03-01T12:00:04Z","tool_id":"read-1","status":"success","output":"contents"}',
493
+ '{"type":"message","timestamp":"2026-03-01T12:00:05Z","role":"assistant","content":"Fixed it","delta":true}',
494
+ '{"type":"result","timestamp":"2026-03-01T12:00:06Z","status":"success","stats":{"total_tokens":1500,"input_tokens":1000,"output_tokens":500,"cached":200,"input":800,"duration_ms":5000,"tool_calls":1}}',
495
+ ].join("\n");
496
+
497
+ const path = join(tempDir, "full-session.jsonl");
498
+ await Bun.write(path, transcript);
499
+
500
+ const result = await runtime.parseTranscript(path);
501
+ expect(result).toEqual({
502
+ inputTokens: 1000,
503
+ outputTokens: 500,
504
+ model: "gemini-2.5-pro",
505
+ });
506
+ });
507
+ });
508
+
509
+ describe("buildEnv", () => {
510
+ test("returns model env vars when present", () => {
511
+ const model: ResolvedModel = {
512
+ model: "gemini-2.5-pro",
513
+ env: { GEMINI_API_KEY: "test-key-123" },
514
+ };
515
+ expect(runtime.buildEnv(model)).toEqual({ GEMINI_API_KEY: "test-key-123" });
516
+ });
517
+
518
+ test("returns empty object when model has no env", () => {
519
+ const model: ResolvedModel = { model: "gemini-2.5-pro" };
520
+ expect(runtime.buildEnv(model)).toEqual({});
521
+ });
522
+
523
+ test("passes through multiple env vars", () => {
524
+ const model: ResolvedModel = {
525
+ model: "gemini-2.5-pro",
526
+ env: {
527
+ GEMINI_API_KEY: "key",
528
+ GOOGLE_CLOUD_PROJECT: "my-project",
529
+ },
530
+ };
531
+ expect(runtime.buildEnv(model)).toEqual({
532
+ GEMINI_API_KEY: "key",
533
+ GOOGLE_CLOUD_PROJECT: "my-project",
534
+ });
535
+ });
536
+ });
537
+ });
@@ -0,0 +1,235 @@
1
+ // Gemini CLI runtime adapter for overstory's AgentRuntime interface.
2
+ // Implements the AgentRuntime contract for Google's `gemini` CLI.
3
+ //
4
+ // Key characteristics:
5
+ // - TUI: `gemini` maintains an interactive Ink-based TUI in tmux
6
+ // - Instruction file: GEMINI.md (read automatically from workspace root)
7
+ // - No hooks: Gemini CLI has no hook/guard mechanism (like Copilot)
8
+ // - Sandbox: `--sandbox` flag + `--approval-mode yolo` for bypass
9
+ // - Headless: `gemini -p "prompt"` for one-shot calls
10
+ // - Transcripts: `--output-format stream-json` produces NDJSON events
11
+
12
+ import { mkdir } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import type { ResolvedModel } from "../types.ts";
15
+ import type {
16
+ AgentRuntime,
17
+ HooksDef,
18
+ OverlayContent,
19
+ ReadyState,
20
+ SpawnOpts,
21
+ TranscriptSummary,
22
+ } from "./types.ts";
23
+
24
+ /**
25
+ * Gemini CLI runtime adapter.
26
+ *
27
+ * Implements AgentRuntime for Google's `gemini` CLI (Gemini coding agent).
28
+ * Gemini maintains an interactive Ink-based TUI, similar to Copilot.
29
+ *
30
+ * Security: Gemini CLI supports `--sandbox` for filesystem isolation
31
+ * (Seatbelt on macOS, container-based on Linux) but has no hook/guard
32
+ * mechanism for per-tool interception. The `_hooks` parameter in
33
+ * `deployConfig` is unused.
34
+ *
35
+ * Instructions are delivered via `GEMINI.md` (Gemini's native context
36
+ * file convention), which the CLI reads automatically from the workspace.
37
+ */
38
+ export class GeminiRuntime implements AgentRuntime {
39
+ /** Unique identifier for this runtime. */
40
+ readonly id = "gemini";
41
+
42
+ /** Relative path to the instruction file within a worktree. */
43
+ readonly instructionPath = "GEMINI.md";
44
+
45
+ /**
46
+ * Build the shell command string to spawn an interactive Gemini agent.
47
+ *
48
+ * Maps SpawnOpts to `gemini` CLI flags:
49
+ * - `model` → `-m <model>`
50
+ * - `permissionMode === "bypass"` → `--approval-mode yolo`
51
+ * - `permissionMode === "ask"` → no approval flag
52
+ * - `appendSystemPrompt` and `appendSystemPromptFile` are IGNORED —
53
+ * the `gemini` CLI has no `--append-system-prompt` equivalent.
54
+ * Role definitions are deployed via GEMINI.md instead.
55
+ *
56
+ * @param opts - Spawn options (model, permissionMode; appendSystemPrompt ignored)
57
+ * @returns Shell command string suitable for tmux new-session -c
58
+ */
59
+ buildSpawnCommand(opts: SpawnOpts): string {
60
+ let cmd = `gemini -m ${opts.model}`;
61
+
62
+ if (opts.permissionMode === "bypass") {
63
+ cmd += " --approval-mode yolo";
64
+ }
65
+
66
+ // appendSystemPrompt and appendSystemPromptFile are intentionally ignored.
67
+ // The gemini CLI has no --append-system-prompt equivalent. Role definitions
68
+ // are deployed via GEMINI.md (the instruction file) by deployConfig().
69
+
70
+ return cmd;
71
+ }
72
+
73
+ /**
74
+ * Build the argv array for a headless one-shot Gemini invocation.
75
+ *
76
+ * Returns an argv array suitable for `Bun.spawn()`. The `-p` flag
77
+ * triggers headless mode — the CLI processes the prompt (including tool
78
+ * invocations) and exits. `--yolo` auto-approves tool calls; without it,
79
+ * unapproved tool calls fail rather than hang.
80
+ *
81
+ * Used by merge/resolver.ts and watchdog/triage.ts for AI-assisted operations.
82
+ *
83
+ * @param prompt - The prompt to pass via `-p`
84
+ * @param model - Optional model override
85
+ * @returns Argv array for Bun.spawn
86
+ */
87
+ buildPrintCommand(prompt: string, model?: string): string[] {
88
+ const cmd = ["gemini", "-p", prompt, "--yolo"];
89
+ if (model !== undefined) {
90
+ cmd.push("-m", model);
91
+ }
92
+ return cmd;
93
+ }
94
+
95
+ /**
96
+ * Deploy per-agent instructions to a worktree.
97
+ *
98
+ * Writes the overlay to `GEMINI.md` in the worktree root (Gemini's
99
+ * native context file convention). The CLI reads GEMINI.md automatically
100
+ * when starting in a directory that contains one.
101
+ *
102
+ * The `hooks` parameter is unused — Gemini CLI has no hook mechanism
103
+ * for per-tool interception. Security depends on `--sandbox` and
104
+ * `--approval-mode` flags instead.
105
+ *
106
+ * @param worktreePath - Absolute path to the agent's git worktree
107
+ * @param overlay - Overlay content to write as GEMINI.md, or undefined to skip
108
+ * @param _hooks - Unused for Gemini runtime
109
+ */
110
+ async deployConfig(
111
+ worktreePath: string,
112
+ overlay: OverlayContent | undefined,
113
+ _hooks: HooksDef,
114
+ ): Promise<void> {
115
+ if (!overlay) return;
116
+
117
+ const geminiPath = join(worktreePath, this.instructionPath);
118
+ await mkdir(dirname(geminiPath), { recursive: true });
119
+ await Bun.write(geminiPath, overlay.content);
120
+ }
121
+
122
+ /**
123
+ * Detect Gemini TUI readiness from a tmux pane content snapshot.
124
+ *
125
+ * Gemini uses an Ink-based React TUI. Detection requires both a
126
+ * prompt indicator AND a Gemini branding indicator:
127
+ *
128
+ * - Prompt: "> " prefix, placeholder "type your message", or U+276F (❯)
129
+ * - Branding: "gemini" visible in the TUI header or status area
130
+ *
131
+ * No trust dialog phase exists for Gemini (unlike Claude Code).
132
+ *
133
+ * @param paneContent - Captured tmux pane content to analyze
134
+ * @returns Current readiness phase (never "dialog" for Gemini)
135
+ */
136
+ detectReady(paneContent: string): ReadyState {
137
+ const lower = paneContent.toLowerCase();
138
+
139
+ // Prompt indicator: placeholder text, "> " at line start, or ❯ character.
140
+ const hasPrompt =
141
+ lower.includes("type your message") ||
142
+ /^> /m.test(paneContent) ||
143
+ paneContent.includes("\u276f");
144
+
145
+ // Branding indicator: "gemini" visible in TUI header/status.
146
+ const hasGemini = lower.includes("gemini");
147
+
148
+ if (hasPrompt && hasGemini) {
149
+ return { phase: "ready" };
150
+ }
151
+
152
+ return { phase: "loading" };
153
+ }
154
+
155
+ /**
156
+ * Parse a Gemini NDJSON transcript file into normalized token usage.
157
+ *
158
+ * Gemini's `--output-format stream-json` produces NDJSON with these events:
159
+ * - `init`: carries `model` and `session_id`
160
+ * - `message`: user/assistant messages (content chunks when delta=true)
161
+ * - `tool_use` / `tool_result`: tool call lifecycle
162
+ * - `result`: final event with `stats.input_tokens` and `stats.output_tokens`
163
+ *
164
+ * Returns null if the file does not exist or cannot be parsed.
165
+ *
166
+ * @param path - Absolute path to the Gemini NDJSON transcript file
167
+ * @returns Aggregated token usage, or null if unavailable
168
+ */
169
+ async parseTranscript(path: string): Promise<TranscriptSummary | null> {
170
+ const file = Bun.file(path);
171
+ if (!(await file.exists())) {
172
+ return null;
173
+ }
174
+
175
+ try {
176
+ const text = await file.text();
177
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
178
+
179
+ let inputTokens = 0;
180
+ let outputTokens = 0;
181
+ let model = "";
182
+
183
+ for (const line of lines) {
184
+ let event: Record<string, unknown>;
185
+ try {
186
+ event = JSON.parse(line) as Record<string, unknown>;
187
+ } catch {
188
+ // Skip malformed lines — partial writes during capture.
189
+ continue;
190
+ }
191
+
192
+ // Model from init event.
193
+ if (event.type === "init" && typeof event.model === "string") {
194
+ model = event.model;
195
+ }
196
+
197
+ // Token usage from result event's stats field.
198
+ if (event.type === "result" && typeof event.stats === "object" && event.stats !== null) {
199
+ const stats = event.stats as Record<string, unknown>;
200
+ const inp = stats.input_tokens;
201
+ const out = stats.output_tokens;
202
+ if (typeof inp === "number") {
203
+ inputTokens += inp;
204
+ }
205
+ if (typeof out === "number") {
206
+ outputTokens += out;
207
+ }
208
+ }
209
+
210
+ // Fallback: capture model from any event that carries it.
211
+ if (typeof event.model === "string" && event.model && !model) {
212
+ model = event.model;
213
+ }
214
+ }
215
+
216
+ return { inputTokens, outputTokens, model };
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Build runtime-specific environment variables for model/provider routing.
224
+ *
225
+ * Returns the provider environment variables from the resolved model.
226
+ * For Google native: may include GEMINI_API_KEY.
227
+ * For gateway providers: may include gateway-specific auth and routing vars.
228
+ *
229
+ * @param model - Resolved model with optional provider env vars
230
+ * @returns Environment variable map (may be empty)
231
+ */
232
+ buildEnv(model: ResolvedModel): Record<string, string> {
233
+ return model.env ?? {};
234
+ }
235
+ }
@@ -3,6 +3,7 @@ import type { OverstoryConfig } from "../types.ts";
3
3
  import { ClaudeRuntime } from "./claude.ts";
4
4
  import { CodexRuntime } from "./codex.ts";
5
5
  import { CopilotRuntime } from "./copilot.ts";
6
+ import { GeminiRuntime } from "./gemini.ts";
6
7
  import { PiRuntime } from "./pi.ts";
7
8
  import { getRuntime } from "./registry.ts";
8
9
 
@@ -21,7 +22,7 @@ describe("getRuntime", () => {
21
22
 
22
23
  it("throws with a helpful message for an unknown runtime", () => {
23
24
  expect(() => getRuntime("unknown-runtime")).toThrow(
24
- 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot',
25
+ 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini',
25
26
  );
26
27
  });
27
28
 
@@ -103,4 +104,17 @@ describe("getRuntime", () => {
103
104
  const b = getRuntime("copilot");
104
105
  expect(a).not.toBe(b);
105
106
  });
107
+
108
+ it("returns GeminiRuntime when name is 'gemini'", () => {
109
+ const runtime = getRuntime("gemini");
110
+ expect(runtime).toBeInstanceOf(GeminiRuntime);
111
+ expect(runtime.id).toBe("gemini");
112
+ });
113
+
114
+ it("uses config.runtime.default 'gemini' when name is omitted", () => {
115
+ const config = { runtime: { default: "gemini" } } as OverstoryConfig;
116
+ const runtime = getRuntime(undefined, config);
117
+ expect(runtime).toBeInstanceOf(GeminiRuntime);
118
+ expect(runtime.id).toBe("gemini");
119
+ });
106
120
  });
@@ -5,6 +5,7 @@ import type { OverstoryConfig } from "../types.ts";
5
5
  import { ClaudeRuntime } from "./claude.ts";
6
6
  import { CodexRuntime } from "./codex.ts";
7
7
  import { CopilotRuntime } from "./copilot.ts";
8
+ import { GeminiRuntime } from "./gemini.ts";
8
9
  import { PiRuntime } from "./pi.ts";
9
10
  import type { AgentRuntime } from "./types.ts";
10
11
 
@@ -14,6 +15,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
14
15
  ["codex", () => new CodexRuntime()],
15
16
  ["pi", () => new PiRuntime()],
16
17
  ["copilot", () => new CopilotRuntime()],
18
+ ["gemini", () => new GeminiRuntime()],
17
19
  ]);
18
20
 
19
21
  /**