diff --git a/cli/package.json b/cli/package.json
index 24a8bf66..089f5a59 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -37,6 +37,7 @@
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
+ "@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts
index 21b915f5..e4443f55 100644
--- a/cli/src/adapters/registry.ts
+++ b/cli/src/adapters/registry.ts
@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
+import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printCursorStreamEvent,
};
+const geminiLocalCLIAdapter: CLIAdapterModule = {
+ type: "gemini_local",
+ formatStdoutEvent: printGeminiStreamEvent,
+};
+
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
@@ -45,6 +51,7 @@ const adaptersByType = new Map(
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
+ geminiLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts
index 00611560..e5c26180 100644
--- a/cli/src/prompts/server.ts
+++ b/cli/src/prompts/server.ts
@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
auth,
};
}
-
diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md
new file mode 100644
index 00000000..51380b05
--- /dev/null
+++ b/docs/adapters/gemini-local.md
@@ -0,0 +1,45 @@
+---
+title: Gemini Local
+summary: Gemini CLI local adapter setup and configuration
+---
+
+The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
+
+## Prerequisites
+
+- Gemini CLI installed (`gemini` command available)
+- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
+
+## Configuration Fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
+| `model` | string | No | Gemini model to use. Defaults to `auto`. |
+| `promptTemplate` | string | No | Prompt used for all runs |
+| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
+| `env` | object | No | Environment variables (supports secret refs) |
+| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
+| `graceSec` | number | No | Grace period before force-kill |
+| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
+
+## Session Persistence
+
+The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
+
+Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
+
+If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
+
+## Skills Injection
+
+The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
+
+## Environment Test
+
+Use the "Test Environment" button in the UI to validate the adapter config. It checks:
+
+- Gemini CLI is installed and accessible
+- Working directory is absolute and available (auto-created if missing and permitted)
+- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
+- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness
diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md
index 4237f87f..44b879d7 100644
--- a/docs/adapters/overview.md
+++ b/docs/adapters/overview.md
@@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
+| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
@@ -54,7 +55,7 @@ Three registries consume these modules:
## Choosing an Adapter
-- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
+- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
- **Need to run a script or command?** Use `process`
- **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json
new file mode 100644
index 00000000..6b214f7e
--- /dev/null
+++ b/packages/adapters/gemini-local/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@paperclipai/adapter-gemini-local",
+ "version": "0.2.7",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./server": "./src/server/index.ts",
+ "./ui": "./src/ui/index.ts",
+ "./cli": "./src/cli/index.ts"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./server": {
+ "types": "./dist/server/index.d.ts",
+ "import": "./dist/server/index.js"
+ },
+ "./ui": {
+ "types": "./dist/ui/index.d.ts",
+ "import": "./dist/ui/index.js"
+ },
+ "./cli": {
+ "types": "./dist/cli/index.d.ts",
+ "import": "./dist/cli/index.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "files": [
+ "dist",
+ "skills"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@paperclipai/adapter-utils": "workspace:*",
+ "picocolors": "^1.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/adapters/gemini-local/src/cli/format-event.ts b/packages/adapters/gemini-local/src/cli/format-event.ts
new file mode 100644
index 00000000..48611f02
--- /dev/null
+++ b/packages/adapters/gemini-local/src/cli/format-event.ts
@@ -0,0 +1,208 @@
+import pc from "picocolors";
+
+function asRecord(value: unknown): Record | null {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ return value as Record;
+}
+
+function asString(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+}
+
+function stringifyUnknown(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (value === null || value === undefined) return "";
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+function errorText(value: unknown): string {
+ if (typeof value === "string") return value;
+ const rec = asRecord(value);
+ if (!rec) return "";
+ const msg =
+ (typeof rec.message === "string" && rec.message) ||
+ (typeof rec.error === "string" && rec.error) ||
+ (typeof rec.code === "string" && rec.code) ||
+ "";
+ if (msg) return msg;
+ try {
+ return JSON.stringify(rec);
+ } catch {
+ return "";
+ }
+}
+
+function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
+ if (typeof messageRaw === "string") {
+ const text = messageRaw.trim();
+ if (text) console.log(colorize(`${prefix}: ${text}`));
+ return;
+ }
+
+ const message = asRecord(messageRaw);
+ if (!message) return;
+
+ const directText = asString(message.text).trim();
+ if (directText) console.log(colorize(`${prefix}: ${directText}`));
+
+ const content = Array.isArray(message.content) ? message.content : [];
+ for (const partRaw of content) {
+ const part = asRecord(partRaw);
+ if (!part) continue;
+ const type = asString(part.type).trim();
+
+ if (type === "output_text" || type === "text" || type === "content") {
+ const text = asString(part.text).trim() || asString(part.content).trim();
+ if (text) console.log(colorize(`${prefix}: ${text}`));
+ continue;
+ }
+
+ if (type === "thinking") {
+ const text = asString(part.text).trim();
+ if (text) console.log(pc.gray(`thinking: ${text}`));
+ continue;
+ }
+
+ if (type === "tool_call") {
+ const name = asString(part.name, asString(part.tool, "tool"));
+ console.log(pc.yellow(`tool_call: ${name}`));
+ const input = part.input ?? part.arguments ?? part.args;
+ if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
+ continue;
+ }
+
+ if (type === "tool_result" || type === "tool_response") {
+ const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
+ const contentText =
+ asString(part.output) ||
+ asString(part.text) ||
+ asString(part.result) ||
+ stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
+ console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
+ if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
+ }
+ }
+}
+
+function printUsage(parsed: Record) {
+ const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
+ const usageMetadata = asRecord(usage?.usageMetadata);
+ const source = usageMetadata ?? usage ?? {};
+ const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
+ const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
+ const cached = asNumber(
+ source.cached_input_tokens,
+ asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
+ );
+ const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
+ console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
+}
+
+export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
+ const line = raw.trim();
+ if (!line) return;
+
+ let parsed: Record | null = null;
+ try {
+ parsed = JSON.parse(line) as Record;
+ } catch {
+ console.log(line);
+ return;
+ }
+
+ const type = asString(parsed.type);
+
+ if (type === "system") {
+ const subtype = asString(parsed.subtype);
+ if (subtype === "init") {
+ const sessionId =
+ asString(parsed.session_id) ||
+ asString(parsed.sessionId) ||
+ asString(parsed.sessionID) ||
+ asString(parsed.checkpoint_id);
+ const model = asString(parsed.model);
+ const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
+ .filter(Boolean)
+ .join(", ");
+ console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
+ return;
+ }
+ if (subtype === "error") {
+ const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
+ if (text) console.log(pc.red(`error: ${text}`));
+ return;
+ }
+ console.log(pc.blue(`system: ${subtype || "event"}`));
+ return;
+ }
+
+ if (type === "assistant") {
+ printTextMessage("assistant", pc.green, parsed.message);
+ return;
+ }
+
+ if (type === "user") {
+ printTextMessage("user", pc.gray, parsed.message);
+ return;
+ }
+
+ if (type === "thinking") {
+ const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
+ if (text) console.log(pc.gray(`thinking: ${text}`));
+ return;
+ }
+
+ if (type === "tool_call") {
+ const subtype = asString(parsed.subtype).trim().toLowerCase();
+ const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
+ const [toolName] = toolCall ? Object.keys(toolCall) : [];
+ if (!toolCall || !toolName) {
+ console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
+ return;
+ }
+ const payload = asRecord(toolCall[toolName]) ?? {};
+ if (subtype === "started" || subtype === "start") {
+ console.log(pc.yellow(`tool_call: ${toolName}`));
+ console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
+ return;
+ }
+ if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
+ const isError =
+ parsed.is_error === true ||
+ payload.is_error === true ||
+ payload.error !== undefined ||
+ asString(payload.status).toLowerCase() === "error";
+ console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
+ console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
+ return;
+ }
+ console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
+ return;
+ }
+
+ if (type === "result") {
+ printUsage(parsed);
+ const subtype = asString(parsed.subtype, "result");
+ const isError = parsed.is_error === true;
+ if (subtype || isError) {
+ console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
+ }
+ return;
+ }
+
+ if (type === "error") {
+ const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
+ if (text) console.log(pc.red(`error: ${text}`));
+ return;
+ }
+
+ console.log(line);
+}
diff --git a/packages/adapters/gemini-local/src/cli/index.ts b/packages/adapters/gemini-local/src/cli/index.ts
new file mode 100644
index 00000000..49ec4426
--- /dev/null
+++ b/packages/adapters/gemini-local/src/cli/index.ts
@@ -0,0 +1 @@
+export { printGeminiStreamEvent } from "./format-event.js";
diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts
new file mode 100644
index 00000000..3ceef32e
--- /dev/null
+++ b/packages/adapters/gemini-local/src/index.ts
@@ -0,0 +1,48 @@
+export const type = "gemini_local";
+export const label = "Gemini CLI (local)";
+export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
+
+export const models = [
+ { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
+ { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
+ { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
+ { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
+];
+
+export const agentConfigurationDoc = `# gemini_local agent configuration
+
+Adapter: gemini_local
+
+Use when:
+- You want Paperclip to run the Gemini CLI locally on the host machine
+- You want Gemini chat sessions resumed across heartbeats with --resume
+- You want Paperclip skills injected locally without polluting the global environment
+
+Don't use when:
+- You need webhook-style external invocation (use http or openclaw_gateway)
+- You only need a one-shot script without an AI coding agent loop (use process)
+- Gemini CLI is not installed on the machine that runs Paperclip
+
+Core fields:
+- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
+- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
+- promptTemplate (string, optional): run prompt template
+- model (string, optional): Gemini model id. Defaults to auto.
+- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
+- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
+- command (string, optional): defaults to "gemini"
+- extraArgs (string[], optional): additional CLI args
+- env (object, optional): KEY=VALUE environment variables
+
+Operational fields:
+- timeoutSec (number, optional): run timeout in seconds
+- graceSec (number, optional): SIGTERM grace period in seconds
+
+Notes:
+- Runs use positional prompt arguments, not stdin.
+- Sessions resume with --resume when stored session cwd matches the current cwd.
+- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
+- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
+`;
diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts
new file mode 100644
index 00000000..37a94232
--- /dev/null
+++ b/packages/adapters/gemini-local/src/server/execute.ts
@@ -0,0 +1,421 @@
+import fs from "node:fs/promises";
+import type { Dirent } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
+import {
+ asBoolean,
+ asNumber,
+ asString,
+ asStringArray,
+ buildPaperclipEnv,
+ ensureAbsoluteDirectory,
+ ensureCommandResolvable,
+ ensurePathInEnv,
+ parseObject,
+ redactEnvForLogs,
+ renderTemplate,
+ runChildProcess,
+} from "@paperclipai/adapter-utils/server-utils";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
+import {
+ describeGeminiFailure,
+ detectGeminiAuthRequired,
+ isGeminiTurnLimitResult,
+ isGeminiUnknownSessionError,
+ parseGeminiJsonl,
+} from "./parse.js";
+import { firstNonEmptyLine } from "./utils.js";
+
+const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
+const PAPERCLIP_SKILLS_CANDIDATES = [
+ path.resolve(__moduleDir, "../../skills"),
+ path.resolve(__moduleDir, "../../../../../skills"),
+];
+
+function hasNonEmptyEnvValue(env: Record, key: string): boolean {
+ const raw = env[key];
+ return typeof raw === "string" && raw.trim().length > 0;
+}
+
+function resolveGeminiBillingType(env: Record): "api" | "subscription" {
+ return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
+ ? "api"
+ : "subscription";
+}
+
+function renderPaperclipEnvNote(env: Record): string {
+ const paperclipKeys = Object.keys(env)
+ .filter((key) => key.startsWith("PAPERCLIP_"))
+ .sort();
+ if (paperclipKeys.length === 0) return "";
+ return [
+ "Paperclip runtime note:",
+ `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
+ "Do not assume these variables are missing without checking your shell environment.",
+ "",
+ "",
+ ].join("\n");
+}
+
+async function resolvePaperclipSkillsDir(): Promise {
+ for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
+ const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
+ if (isDir) return candidate;
+ }
+ return null;
+}
+
+function geminiSkillsHome(): string {
+ return path.join(os.homedir(), ".gemini", "skills");
+}
+
+/**
+ * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
+ * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
+ * both its auth credentials and the injected skills in the real home directory.
+ */
+async function ensureGeminiSkillsInjected(
+ onLog: AdapterExecutionContext["onLog"],
+): Promise {
+ const skillsDir = await resolvePaperclipSkillsDir();
+ if (!skillsDir) return;
+
+ const skillsHome = geminiSkillsHome();
+ try {
+ await fs.mkdir(skillsHome, { recursive: true });
+ } catch (err) {
+ await onLog(
+ "stderr",
+ `[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
+ );
+ return;
+ }
+
+ let entries: Dirent[];
+ try {
+ entries = await fs.readdir(skillsDir, { withFileTypes: true });
+ } catch (err) {
+ await onLog(
+ "stderr",
+ `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
+ );
+ return;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const source = path.join(skillsDir, entry.name);
+ const target = path.join(skillsHome, entry.name);
+ const existing = await fs.lstat(target).catch(() => null);
+ if (existing) continue;
+
+ try {
+ await fs.symlink(source, target);
+ await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
+ } catch (err) {
+ await onLog(
+ "stderr",
+ `[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
+ );
+ }
+ }
+}
+
+export async function execute(ctx: AdapterExecutionContext): Promise {
+ const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
+
+ const promptTemplate = asString(
+ config.promptTemplate,
+ "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
+ );
+ const command = asString(config.command, "gemini");
+ const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
+ const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
+ const sandbox = asBoolean(config.sandbox, false);
+
+ const workspaceContext = parseObject(context.paperclipWorkspace);
+ const workspaceCwd = asString(workspaceContext.cwd, "");
+ const workspaceSource = asString(workspaceContext.source, "");
+ const workspaceId = asString(workspaceContext.workspaceId, "");
+ const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
+ const workspaceRepoRef = asString(workspaceContext.repoRef, "");
+ const workspaceHints = Array.isArray(context.paperclipWorkspaces)
+ ? context.paperclipWorkspaces.filter(
+ (value): value is Record => typeof value === "object" && value !== null,
+ )
+ : [];
+ const configuredCwd = asString(config.cwd, "");
+ const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
+ const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
+ const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
+ await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
+ await ensureGeminiSkillsInjected(onLog);
+
+ const envConfig = parseObject(config.env);
+ const hasExplicitApiKey =
+ typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
+ const env: Record = { ...buildPaperclipEnv(agent) };
+ env.PAPERCLIP_RUN_ID = runId;
+ const wakeTaskId =
+ (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
+ (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
+ null;
+ const wakeReason =
+ typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
+ ? context.wakeReason.trim()
+ : null;
+ const wakeCommentId =
+ (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
+ (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
+ null;
+ const approvalId =
+ typeof context.approvalId === "string" && context.approvalId.trim().length > 0
+ ? context.approvalId.trim()
+ : null;
+ const approvalStatus =
+ typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
+ ? context.approvalStatus.trim()
+ : null;
+ const linkedIssueIds = Array.isArray(context.issueIds)
+ ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
+ : [];
+ if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
+ if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
+ if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
+ if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
+ if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
+ if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
+ if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
+ if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
+ if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
+ if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
+ if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
+ if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
+
+ for (const [key, value] of Object.entries(envConfig)) {
+ if (typeof value === "string") env[key] = value;
+ }
+ if (!hasExplicitApiKey && authToken) {
+ env.PAPERCLIP_API_KEY = authToken;
+ }
+ const billingType = resolveGeminiBillingType(env);
+ const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
+ await ensureCommandResolvable(command, cwd, runtimeEnv);
+
+ const timeoutSec = asNumber(config.timeoutSec, 0);
+ const graceSec = asNumber(config.graceSec, 20);
+ const extraArgs = (() => {
+ const fromExtraArgs = asStringArray(config.extraArgs);
+ if (fromExtraArgs.length > 0) return fromExtraArgs;
+ return asStringArray(config.args);
+ })();
+
+ const runtimeSessionParams = parseObject(runtime.sessionParams);
+ const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
+ const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
+ const canResumeSession =
+ runtimeSessionId.length > 0 &&
+ (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
+ const sessionId = canResumeSession ? runtimeSessionId : null;
+ if (runtimeSessionId && !canResumeSession) {
+ await onLog(
+ "stderr",
+ `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
+ );
+ }
+
+ const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
+ const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
+ let instructionsPrefix = "";
+ if (instructionsFilePath) {
+ try {
+ const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
+ instructionsPrefix =
+ `${instructionsContents}\n\n` +
+ `The above agent instructions were loaded from ${instructionsFilePath}. ` +
+ `Resolve any relative file references from ${instructionsDir}.\n\n`;
+ await onLog(
+ "stderr",
+ `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
+ );
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err);
+ await onLog(
+ "stderr",
+ `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
+ );
+ }
+ }
+ const commandNotes = (() => {
+ const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
+ if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
+ if (!instructionsFilePath) return notes;
+ if (instructionsPrefix.length > 0) {
+ notes.push(
+ `Loaded agent instructions from ${instructionsFilePath}`,
+ `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
+ );
+ return notes;
+ }
+ notes.push(
+ `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
+ );
+ return notes;
+ })();
+
+ const renderedPrompt = renderTemplate(promptTemplate, {
+ agentId: agent.id,
+ companyId: agent.companyId,
+ runId,
+ company: { id: agent.companyId },
+ agent,
+ run: { id: runId, source: "on_demand" },
+ context,
+ });
+ const paperclipEnvNote = renderPaperclipEnvNote(env);
+ const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
+
+ const buildArgs = (resumeSessionId: string | null) => {
+ const args = ["--output-format", "stream-json"];
+ if (resumeSessionId) args.push("--resume", resumeSessionId);
+ if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
+ if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
+ if (sandbox) {
+ args.push("--sandbox");
+ } else {
+ args.push("--sandbox=none");
+ }
+ if (extraArgs.length > 0) args.push(...extraArgs);
+ args.push(prompt);
+ return args;
+ };
+
+ const runAttempt = async (resumeSessionId: string | null) => {
+ const args = buildArgs(resumeSessionId);
+ if (onMeta) {
+ await onMeta({
+ adapterType: "gemini_local",
+ command,
+ cwd,
+ commandNotes,
+ commandArgs: args.map((value, index) => (
+ index === args.length - 1 ? `` : value
+ )),
+ env: redactEnvForLogs(env),
+ prompt,
+ context,
+ });
+ }
+
+ const proc = await runChildProcess(runId, command, args, {
+ cwd,
+ env,
+ timeoutSec,
+ graceSec,
+ onLog,
+ });
+ return {
+ proc,
+ parsed: parseGeminiJsonl(proc.stdout),
+ };
+ };
+
+ const toResult = (
+ attempt: {
+ proc: {
+ exitCode: number | null;
+ signal: string | null;
+ timedOut: boolean;
+ stdout: string;
+ stderr: string;
+ };
+ parsed: ReturnType;
+ },
+ clearSessionOnMissingSession = false,
+ isRetry = false,
+ ): AdapterExecutionResult => {
+ const authMeta = detectGeminiAuthRequired({
+ parsed: attempt.parsed.resultEvent,
+ stdout: attempt.proc.stdout,
+ stderr: attempt.proc.stderr,
+ });
+
+ if (attempt.proc.timedOut) {
+ return {
+ exitCode: attempt.proc.exitCode,
+ signal: attempt.proc.signal,
+ timedOut: true,
+ errorMessage: `Timed out after ${timeoutSec}s`,
+ errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
+ clearSession: clearSessionOnMissingSession,
+ };
+ }
+
+ const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
+
+ // On retry, don't fall back to old session ID — the old session was stale
+ const canFallbackToRuntimeSession = !isRetry;
+ const resolvedSessionId = attempt.parsed.sessionId
+ ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
+ const resolvedSessionParams = resolvedSessionId
+ ? ({
+ sessionId: resolvedSessionId,
+ cwd,
+ ...(workspaceId ? { workspaceId } : {}),
+ ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
+ ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
+ } as Record)
+ : null;
+ const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
+ const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
+ const structuredFailure = attempt.parsed.resultEvent
+ ? describeGeminiFailure(attempt.parsed.resultEvent)
+ : null;
+ const fallbackErrorMessage =
+ parsedError ||
+ structuredFailure ||
+ stderrLine ||
+ `Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
+
+ return {
+ exitCode: attempt.proc.exitCode,
+ signal: attempt.proc.signal,
+ timedOut: false,
+ errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
+ errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
+ usage: attempt.parsed.usage,
+ sessionId: resolvedSessionId,
+ sessionParams: resolvedSessionParams,
+ sessionDisplayId: resolvedSessionId,
+ provider: "google",
+ model,
+ billingType,
+ costUsd: attempt.parsed.costUsd,
+ resultJson: attempt.parsed.resultEvent ?? {
+ stdout: attempt.proc.stdout,
+ stderr: attempt.proc.stderr,
+ },
+ summary: attempt.parsed.summary,
+ clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
+ };
+ };
+
+ const initial = await runAttempt(sessionId);
+ if (
+ sessionId &&
+ !initial.proc.timedOut &&
+ (initial.proc.exitCode ?? 0) !== 0 &&
+ isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
+ ) {
+ await onLog(
+ "stderr",
+ `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
+ );
+ const retry = await runAttempt(null);
+ return toResult(retry, true, true);
+ }
+
+ return toResult(initial);
+}
diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts
new file mode 100644
index 00000000..1d35a2bf
--- /dev/null
+++ b/packages/adapters/gemini-local/src/server/index.ts
@@ -0,0 +1,70 @@
+export { execute } from "./execute.js";
+export { testEnvironment } from "./test.js";
+export {
+ parseGeminiJsonl,
+ isGeminiUnknownSessionError,
+ describeGeminiFailure,
+ detectGeminiAuthRequired,
+ isGeminiTurnLimitResult,
+} from "./parse.js";
+import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
+
+function readNonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+export const sessionCodec: AdapterSessionCodec = {
+ deserialize(raw: unknown) {
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
+ const record = raw as Record;
+ const sessionId =
+ readNonEmptyString(record.sessionId) ??
+ readNonEmptyString(record.session_id) ??
+ readNonEmptyString(record.sessionID);
+ if (!sessionId) return null;
+ const cwd =
+ readNonEmptyString(record.cwd) ??
+ readNonEmptyString(record.workdir) ??
+ readNonEmptyString(record.folder);
+ const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
+ const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
+ const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
+ return {
+ sessionId,
+ ...(cwd ? { cwd } : {}),
+ ...(workspaceId ? { workspaceId } : {}),
+ ...(repoUrl ? { repoUrl } : {}),
+ ...(repoRef ? { repoRef } : {}),
+ };
+ },
+ serialize(params: Record | null) {
+ if (!params) return null;
+ const sessionId =
+ readNonEmptyString(params.sessionId) ??
+ readNonEmptyString(params.session_id) ??
+ readNonEmptyString(params.sessionID);
+ if (!sessionId) return null;
+ const cwd =
+ readNonEmptyString(params.cwd) ??
+ readNonEmptyString(params.workdir) ??
+ readNonEmptyString(params.folder);
+ const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
+ const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
+ const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
+ return {
+ sessionId,
+ ...(cwd ? { cwd } : {}),
+ ...(workspaceId ? { workspaceId } : {}),
+ ...(repoUrl ? { repoUrl } : {}),
+ ...(repoRef ? { repoRef } : {}),
+ };
+ },
+ getDisplayId(params: Record | null) {
+ if (!params) return null;
+ return (
+ readNonEmptyString(params.sessionId) ??
+ readNonEmptyString(params.session_id) ??
+ readNonEmptyString(params.sessionID)
+ );
+ },
+};
diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts
new file mode 100644
index 00000000..f25b0e88
--- /dev/null
+++ b/packages/adapters/gemini-local/src/server/parse.ts
@@ -0,0 +1,242 @@
+import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
+
+function collectMessageText(message: unknown): string[] {
+ if (typeof message === "string") {
+ const trimmed = message.trim();
+ return trimmed ? [trimmed] : [];
+ }
+
+ const record = parseObject(message);
+ const direct = asString(record.text, "").trim();
+ const lines: string[] = direct ? [direct] : [];
+ const content = Array.isArray(record.content) ? record.content : [];
+
+ for (const partRaw of content) {
+ const part = parseObject(partRaw);
+ const type = asString(part.type, "").trim();
+ if (type === "output_text" || type === "text" || type === "content") {
+ const text = asString(part.text, "").trim() || asString(part.content, "").trim();
+ if (text) lines.push(text);
+ }
+ }
+
+ return lines;
+}
+
+function readSessionId(event: Record): string | null {
+ return (
+ asString(event.session_id, "").trim() ||
+ asString(event.sessionId, "").trim() ||
+ asString(event.sessionID, "").trim() ||
+ asString(event.checkpoint_id, "").trim() ||
+ asString(event.thread_id, "").trim() ||
+ null
+ );
+}
+
+function asErrorText(value: unknown): string {
+ if (typeof value === "string") return value;
+ const rec = parseObject(value);
+ const message =
+ asString(rec.message, "") ||
+ asString(rec.error, "") ||
+ asString(rec.code, "") ||
+ asString(rec.detail, "");
+ if (message) return message;
+ try {
+ return JSON.stringify(rec);
+ } catch {
+ return "";
+ }
+}
+
+function accumulateUsage(
+ target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
+ usageRaw: unknown,
+) {
+ const usage = parseObject(usageRaw);
+ const usageMetadata = parseObject(usage.usageMetadata);
+ const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
+
+ target.inputTokens += asNumber(
+ source.input_tokens,
+ asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
+ );
+ target.cachedInputTokens += asNumber(
+ source.cached_input_tokens,
+ asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
+ );
+ target.outputTokens += asNumber(
+ source.output_tokens,
+ asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
+ );
+}
+
+export function parseGeminiJsonl(stdout: string) {
+ let sessionId: string | null = null;
+ const messages: string[] = [];
+ let errorMessage: string | null = null;
+ let costUsd: number | null = null;
+ let resultEvent: Record | null = null;
+ const usage = {
+ inputTokens: 0,
+ cachedInputTokens: 0,
+ outputTokens: 0,
+ };
+
+ for (const rawLine of stdout.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line) continue;
+
+ const event = parseJson(line);
+ if (!event) continue;
+
+ const foundSessionId = readSessionId(event);
+ if (foundSessionId) sessionId = foundSessionId;
+
+ const type = asString(event.type, "").trim();
+
+ if (type === "assistant") {
+ messages.push(...collectMessageText(event.message));
+ continue;
+ }
+
+ if (type === "result") {
+ resultEvent = event;
+ accumulateUsage(usage, event.usage ?? event.usageMetadata);
+ const resultText =
+ asString(event.result, "").trim() ||
+ asString(event.text, "").trim() ||
+ asString(event.response, "").trim();
+ if (resultText && messages.length === 0) messages.push(resultText);
+ costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
+ const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
+ if (isError) {
+ const text = asErrorText(event.error ?? event.message ?? event.result).trim();
+ if (text) errorMessage = text;
+ }
+ continue;
+ }
+
+ if (type === "error") {
+ const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
+ if (text) errorMessage = text;
+ continue;
+ }
+
+ if (type === "system") {
+ const subtype = asString(event.subtype, "").trim().toLowerCase();
+ if (subtype === "error") {
+ const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
+ if (text) errorMessage = text;
+ }
+ continue;
+ }
+
+ if (type === "text") {
+ const part = parseObject(event.part);
+ const text = asString(part.text, "").trim();
+ if (text) messages.push(text);
+ continue;
+ }
+
+ if (type === "step_finish" || event.usage || event.usageMetadata) {
+ accumulateUsage(usage, event.usage ?? event.usageMetadata);
+ costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
+ continue;
+ }
+ }
+
+ return {
+ sessionId,
+ summary: messages.join("\n\n").trim(),
+ usage,
+ costUsd,
+ errorMessage,
+ resultEvent,
+ };
+}
+
+export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
+ const haystack = `${stdout}\n${stderr}`
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .join("\n");
+
+ return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
+ haystack,
+ );
+}
+
+function extractGeminiErrorMessages(parsed: Record): string[] {
+ const messages: string[] = [];
+ const errorMsg = asString(parsed.error, "").trim();
+ if (errorMsg) messages.push(errorMsg);
+
+ const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
+ for (const entry of raw) {
+ if (typeof entry === "string") {
+ const msg = entry.trim();
+ if (msg) messages.push(msg);
+ continue;
+ }
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
+ const obj = entry as Record;
+ const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
+ if (msg) {
+ messages.push(msg);
+ continue;
+ }
+ try {
+ messages.push(JSON.stringify(obj));
+ } catch {
+ // skip non-serializable entry
+ }
+ }
+
+ return messages;
+}
+
+export function describeGeminiFailure(parsed: Record): string | null {
+ const status = asString(parsed.status, "");
+ const errors = extractGeminiErrorMessages(parsed);
+
+ const detail = errors[0] ?? "";
+ const parts = ["Gemini run failed"];
+ if (status) parts.push(`status=${status}`);
+ if (detail) parts.push(detail);
+ return parts.length > 1 ? parts.join(": ") : null;
+}
+
+const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
+
+export function detectGeminiAuthRequired(input: {
+ parsed: Record | null;
+ stdout: string;
+ stderr: string;
+}): { requiresAuth: boolean } {
+ const errors = extractGeminiErrorMessages(input.parsed ?? {});
+ const messages = [...errors, input.stdout, input.stderr]
+ .join("\n")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
+ return { requiresAuth };
+}
+
+export function isGeminiTurnLimitResult(
+ parsed: Record | null | undefined,
+ exitCode?: number | null,
+): boolean {
+ if (exitCode === 53) return true;
+ if (!parsed) return false;
+
+ const status = asString(parsed.status, "").trim().toLowerCase();
+ if (status === "turn_limit" || status === "max_turns") return true;
+
+ const error = asString(parsed.error, "").trim();
+ return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
+}
diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts
new file mode 100644
index 00000000..8f63e5e2
--- /dev/null
+++ b/packages/adapters/gemini-local/src/server/test.ts
@@ -0,0 +1,223 @@
+import path from "node:path";
+import type {
+ AdapterEnvironmentCheck,
+ AdapterEnvironmentTestContext,
+ AdapterEnvironmentTestResult,
+} from "@paperclipai/adapter-utils";
+import {
+ asBoolean,
+ asString,
+ asStringArray,
+ ensureAbsoluteDirectory,
+ ensureCommandResolvable,
+ ensurePathInEnv,
+ parseObject,
+ runChildProcess,
+} from "@paperclipai/adapter-utils/server-utils";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
+import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
+import { firstNonEmptyLine } from "./utils.js";
+
+function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
+ if (checks.some((check) => check.level === "error")) return "fail";
+ if (checks.some((check) => check.level === "warn")) return "warn";
+ return "pass";
+}
+
+function isNonEmpty(value: unknown): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function commandLooksLike(command: string, expected: string): boolean {
+ const base = path.basename(command).toLowerCase();
+ return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
+}
+
+function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
+ const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
+ if (!raw) return null;
+ const clean = raw.replace(/\s+/g, " ").trim();
+ const max = 240;
+ return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
+}
+
+export async function testEnvironment(
+ ctx: AdapterEnvironmentTestContext,
+): Promise {
+ const checks: AdapterEnvironmentCheck[] = [];
+ const config = parseObject(ctx.config);
+ const command = asString(config.command, "gemini");
+ const cwd = asString(config.cwd, process.cwd());
+
+ try {
+ await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
+ checks.push({
+ code: "gemini_cwd_valid",
+ level: "info",
+ message: `Working directory is valid: ${cwd}`,
+ });
+ } catch (err) {
+ checks.push({
+ code: "gemini_cwd_invalid",
+ level: "error",
+ message: err instanceof Error ? err.message : "Invalid working directory",
+ detail: cwd,
+ });
+ }
+
+ const envConfig = parseObject(config.env);
+ const env: Record = {};
+ for (const [key, value] of Object.entries(envConfig)) {
+ if (typeof value === "string") env[key] = value;
+ }
+ const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
+ try {
+ await ensureCommandResolvable(command, cwd, runtimeEnv);
+ checks.push({
+ code: "gemini_command_resolvable",
+ level: "info",
+ message: `Command is executable: ${command}`,
+ });
+ } catch (err) {
+ checks.push({
+ code: "gemini_command_unresolvable",
+ level: "error",
+ message: err instanceof Error ? err.message : "Command is not executable",
+ detail: command,
+ });
+ }
+
+ const configGeminiApiKey = env.GEMINI_API_KEY;
+ const hostGeminiApiKey = process.env.GEMINI_API_KEY;
+ const configGoogleApiKey = env.GOOGLE_API_KEY;
+ const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
+ const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
+ if (
+ isNonEmpty(configGeminiApiKey) ||
+ isNonEmpty(hostGeminiApiKey) ||
+ isNonEmpty(configGoogleApiKey) ||
+ isNonEmpty(hostGoogleApiKey) ||
+ hasGca
+ ) {
+ const source = hasGca
+ ? "Google account login (GCA)"
+ : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
+ ? "adapter config env"
+ : "server environment";
+ checks.push({
+ code: "gemini_api_key_present",
+ level: "info",
+ message: "Gemini API credentials are set for CLI authentication.",
+ detail: `Detected in ${source}.`,
+ });
+ } else {
+ checks.push({
+ code: "gemini_api_key_missing",
+ level: "info",
+ message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
+ hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
+ });
+ }
+
+ const canRunProbe =
+ checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
+ if (canRunProbe) {
+ if (!commandLooksLike(command, "gemini")) {
+ checks.push({
+ code: "gemini_hello_probe_skipped_custom_command",
+ level: "info",
+ message: "Skipped hello probe because command is not `gemini`.",
+ detail: command,
+ hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
+ });
+ } else {
+ const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
+ const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
+ const sandbox = asBoolean(config.sandbox, false);
+ const extraArgs = (() => {
+ const fromExtraArgs = asStringArray(config.extraArgs);
+ if (fromExtraArgs.length > 0) return fromExtraArgs;
+ return asStringArray(config.args);
+ })();
+
+ const args = ["--output-format", "stream-json"];
+ if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
+ if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
+ if (sandbox) {
+ args.push("--sandbox");
+ } else {
+ args.push("--sandbox=none");
+ }
+ if (extraArgs.length > 0) args.push(...extraArgs);
+ args.push("Respond with hello.");
+
+ const probe = await runChildProcess(
+ `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ command,
+ args,
+ {
+ cwd,
+ env,
+ timeoutSec: 45,
+ graceSec: 5,
+ onLog: async () => { },
+ },
+ );
+ const parsed = parseGeminiJsonl(probe.stdout);
+ const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
+ const authMeta = detectGeminiAuthRequired({
+ parsed: parsed.resultEvent,
+ stdout: probe.stdout,
+ stderr: probe.stderr,
+ });
+
+ if (probe.timedOut) {
+ checks.push({
+ code: "gemini_hello_probe_timed_out",
+ level: "warn",
+ message: "Gemini hello probe timed out.",
+ hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
+ });
+ } else if ((probe.exitCode ?? 1) === 0) {
+ const summary = parsed.summary.trim();
+ const hasHello = /\bhello\b/i.test(summary);
+ checks.push({
+ code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
+ level: hasHello ? "info" : "warn",
+ message: hasHello
+ ? "Gemini hello probe succeeded."
+ : "Gemini probe ran but did not return `hello` as expected.",
+ ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
+ ...(hasHello
+ ? {}
+ : {
+ hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
+ }),
+ });
+ } else if (authMeta.requiresAuth) {
+ checks.push({
+ code: "gemini_hello_probe_auth_required",
+ level: "warn",
+ message: "Gemini CLI is installed, but authentication is not ready.",
+ ...(detail ? { detail } : {}),
+ hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
+ });
+ } else {
+ checks.push({
+ code: "gemini_hello_probe_failed",
+ level: "error",
+ message: "Gemini hello probe failed.",
+ ...(detail ? { detail } : {}),
+ hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
+ });
+ }
+ }
+ }
+
+ return {
+ adapterType: ctx.adapterType,
+ status: summarizeStatus(checks),
+ checks,
+ testedAt: new Date().toISOString(),
+ };
+}
diff --git a/packages/adapters/gemini-local/src/server/utils.ts b/packages/adapters/gemini-local/src/server/utils.ts
new file mode 100644
index 00000000..fb11c75d
--- /dev/null
+++ b/packages/adapters/gemini-local/src/server/utils.ts
@@ -0,0 +1,8 @@
+export function firstNonEmptyLine(text: string): string {
+ return (
+ text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find(Boolean) ?? ""
+ );
+}
diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts
new file mode 100644
index 00000000..1fd7ac65
--- /dev/null
+++ b/packages/adapters/gemini-local/src/ui/build-config.ts
@@ -0,0 +1,75 @@
+import type { CreateConfigValues } from "@paperclipai/adapter-utils";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
+
+function parseCommaArgs(value: string): string[] {
+ return value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function parseEnvVars(text: string): Record {
+ const env: Record = {};
+ for (const line of text.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const eq = trimmed.indexOf("=");
+ if (eq <= 0) continue;
+ const key = trimmed.slice(0, eq).trim();
+ const value = trimmed.slice(eq + 1);
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
+ env[key] = value;
+ }
+ return env;
+}
+
+function parseEnvBindings(bindings: unknown): Record {
+ if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
+ const env: Record = {};
+ for (const [key, raw] of Object.entries(bindings)) {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
+ if (typeof raw === "string") {
+ env[key] = { type: "plain", value: raw };
+ continue;
+ }
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
+ const rec = raw as Record;
+ if (rec.type === "plain" && typeof rec.value === "string") {
+ env[key] = { type: "plain", value: rec.value };
+ continue;
+ }
+ if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
+ env[key] = {
+ type: "secret_ref",
+ secretId: rec.secretId,
+ ...(typeof rec.version === "number" || rec.version === "latest"
+ ? { version: rec.version }
+ : {}),
+ };
+ }
+ }
+ return env;
+}
+
+export function buildGeminiLocalConfig(v: CreateConfigValues): Record {
+ const ac: Record = {};
+ if (v.cwd) ac.cwd = v.cwd;
+ if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
+ if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
+ ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
+ ac.timeoutSec = 0;
+ ac.graceSec = 15;
+ const env = parseEnvBindings(v.envBindings);
+ const legacy = parseEnvVars(v.envVars);
+ for (const [key, value] of Object.entries(legacy)) {
+ if (!Object.prototype.hasOwnProperty.call(env, key)) {
+ env[key] = { type: "plain", value };
+ }
+ }
+ if (Object.keys(env).length > 0) ac.env = env;
+ if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
+ ac.sandbox = !v.dangerouslyBypassSandbox;
+ if (v.command) ac.command = v.command;
+ if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
+ return ac;
+}
diff --git a/packages/adapters/gemini-local/src/ui/index.ts b/packages/adapters/gemini-local/src/ui/index.ts
new file mode 100644
index 00000000..5d7708b1
--- /dev/null
+++ b/packages/adapters/gemini-local/src/ui/index.ts
@@ -0,0 +1,2 @@
+export { parseGeminiStdoutLine } from "./parse-stdout.js";
+export { buildGeminiLocalConfig } from "./build-config.js";
diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.ts
new file mode 100644
index 00000000..47426fa3
--- /dev/null
+++ b/packages/adapters/gemini-local/src/ui/parse-stdout.ts
@@ -0,0 +1,274 @@
+import type { TranscriptEntry } from "@paperclipai/adapter-utils";
+
+function safeJsonParse(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+function asRecord(value: unknown): Record | null {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
+ return value as Record;
+}
+
+function asString(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+}
+
+function stringifyUnknown(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (value === null || value === undefined) return "";
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+function errorText(value: unknown): string {
+ if (typeof value === "string") return value;
+ const rec = asRecord(value);
+ if (!rec) return "";
+ const msg =
+ (typeof rec.message === "string" && rec.message) ||
+ (typeof rec.error === "string" && rec.error) ||
+ (typeof rec.code === "string" && rec.code) ||
+ "";
+ if (msg) return msg;
+ try {
+ return JSON.stringify(rec);
+ } catch {
+ return "";
+ }
+}
+
+function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
+ if (typeof messageRaw === "string") {
+ const text = messageRaw.trim();
+ return text ? [{ kind, ts, text }] : [];
+ }
+
+ const message = asRecord(messageRaw);
+ if (!message) return [];
+
+ const entries: TranscriptEntry[] = [];
+ const directText = asString(message.text).trim();
+ if (directText) entries.push({ kind, ts, text: directText });
+
+ const content = Array.isArray(message.content) ? message.content : [];
+ for (const partRaw of content) {
+ const part = asRecord(partRaw);
+ if (!part) continue;
+ const type = asString(part.type).trim();
+ if (type !== "output_text" && type !== "text" && type !== "content") continue;
+ const text = asString(part.text).trim() || asString(part.content).trim();
+ if (text) entries.push({ kind, ts, text });
+ }
+
+ return entries;
+}
+
+function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
+ if (typeof messageRaw === "string") {
+ const text = messageRaw.trim();
+ return text ? [{ kind: "assistant", ts, text }] : [];
+ }
+
+ const message = asRecord(messageRaw);
+ if (!message) return [];
+
+ const entries: TranscriptEntry[] = [];
+ const directText = asString(message.text).trim();
+ if (directText) entries.push({ kind: "assistant", ts, text: directText });
+
+ const content = Array.isArray(message.content) ? message.content : [];
+ for (const partRaw of content) {
+ const part = asRecord(partRaw);
+ if (!part) continue;
+ const type = asString(part.type).trim();
+
+ if (type === "output_text" || type === "text" || type === "content") {
+ const text = asString(part.text).trim() || asString(part.content).trim();
+ if (text) entries.push({ kind: "assistant", ts, text });
+ continue;
+ }
+
+ if (type === "thinking") {
+ const text = asString(part.text).trim();
+ if (text) entries.push({ kind: "thinking", ts, text });
+ continue;
+ }
+
+ if (type === "tool_call") {
+ const name = asString(part.name, asString(part.tool, "tool"));
+ entries.push({
+ kind: "tool_call",
+ ts,
+ name,
+ input: part.input ?? part.arguments ?? part.args ?? {},
+ });
+ continue;
+ }
+
+ if (type === "tool_result" || type === "tool_response") {
+ const toolUseId =
+ asString(part.tool_use_id) ||
+ asString(part.toolUseId) ||
+ asString(part.call_id) ||
+ asString(part.id) ||
+ "tool_result";
+ const contentText =
+ asString(part.output) ||
+ asString(part.text) ||
+ asString(part.result) ||
+ stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
+ const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
+ entries.push({
+ kind: "tool_result",
+ ts,
+ toolUseId,
+ content: contentText,
+ isError,
+ });
+ }
+ }
+
+ return entries;
+}
+
+function parseTopLevelToolEvent(parsed: Record, ts: string): TranscriptEntry[] {
+ const subtype = asString(parsed.subtype).trim().toLowerCase();
+ const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
+ const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
+ if (!toolCall) {
+ return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
+ }
+
+ const [toolName] = Object.keys(toolCall);
+ if (!toolName) {
+ return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
+ }
+ const payload = asRecord(toolCall[toolName]) ?? {};
+
+ if (subtype === "started" || subtype === "start") {
+ return [{
+ kind: "tool_call",
+ ts,
+ name: toolName,
+ input: payload.args ?? payload.input ?? payload.arguments ?? payload,
+ }];
+ }
+
+ if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
+ const result = payload.result ?? payload.output ?? payload.error;
+ const isError =
+ parsed.is_error === true ||
+ payload.is_error === true ||
+ payload.error !== undefined ||
+ asString(payload.status).toLowerCase() === "error";
+ return [{
+ kind: "tool_result",
+ ts,
+ toolUseId: callId,
+ content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
+ isError,
+ }];
+ }
+
+ return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
+}
+
+function readSessionId(parsed: Record): string {
+ return (
+ asString(parsed.session_id) ||
+ asString(parsed.sessionId) ||
+ asString(parsed.sessionID) ||
+ asString(parsed.checkpoint_id) ||
+ asString(parsed.thread_id)
+ );
+}
+
+function readUsage(parsed: Record) {
+ const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
+ const usageMetadata = asRecord(usage?.usageMetadata);
+ const source = usageMetadata ?? usage ?? {};
+ return {
+ inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
+ outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
+ cachedTokens: asNumber(
+ source.cached_input_tokens,
+ asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
+ ),
+ };
+}
+
+export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
+ const parsed = asRecord(safeJsonParse(line));
+ if (!parsed) {
+ return [{ kind: "stdout", ts, text: line }];
+ }
+
+ const type = asString(parsed.type);
+
+ if (type === "system") {
+ const subtype = asString(parsed.subtype);
+ if (subtype === "init") {
+ const sessionId = readSessionId(parsed);
+ return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
+ }
+ if (subtype === "error") {
+ const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
+ return [{ kind: "stderr", ts, text: text || "error" }];
+ }
+ return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
+ }
+
+ if (type === "assistant") {
+ return parseAssistantMessage(parsed.message, ts);
+ }
+
+ if (type === "user") {
+ return collectTextEntries(parsed.message, ts, "user");
+ }
+
+ if (type === "thinking") {
+ const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
+ return text ? [{ kind: "thinking", ts, text }] : [];
+ }
+
+ if (type === "tool_call") {
+ return parseTopLevelToolEvent(parsed, ts);
+ }
+
+ if (type === "result") {
+ const usage = readUsage(parsed);
+ const errors = parsed.is_error === true
+ ? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
+ : [];
+ return [{
+ kind: "result",
+ ts,
+ text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
+ inputTokens: usage.inputTokens,
+ outputTokens: usage.outputTokens,
+ cachedTokens: usage.cachedTokens,
+ costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
+ subtype: asString(parsed.subtype, "result"),
+ isError: parsed.is_error === true,
+ errors,
+ }];
+ }
+
+ if (type === "error") {
+ const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
+ return [{ kind: "stderr", ts, text: text || "error" }];
+ }
+
+ return [{ kind: "stdout", ts, text: line }];
+}
diff --git a/packages/adapters/gemini-local/tsconfig.json b/packages/adapters/gemini-local/tsconfig.json
new file mode 100644
index 00000000..2f355cfe
--- /dev/null
+++ b/packages/adapters/gemini-local/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index ba75dc8e..bf2d3665 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
"http",
"claude_local",
"codex_local",
+ "gemini_local",
"opencode_local",
"pi_local",
"cursor",
diff --git a/server/package.json b/server/package.json
index aeb09944..1dd9b073 100644
--- a/server/package.json
+++ b/server/package.json
@@ -37,6 +37,7 @@
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
+ "@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts
index 82285b6a..acac2692 100644
--- a/server/src/__tests__/adapter-session-codecs.test.ts
+++ b/server/src/__tests__/adapter-session-codecs.test.ts
@@ -5,6 +5,10 @@ import {
sessionCodec as cursorSessionCodec,
isCursorUnknownSessionError,
} from "@paperclipai/adapter-cursor-local/server";
+import {
+ sessionCodec as geminiSessionCodec,
+ isGeminiUnknownSessionError,
+} from "@paperclipai/adapter-gemini-local/server";
import {
sessionCodec as opencodeSessionCodec,
isOpenCodeUnknownSessionError,
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
});
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
});
+
+ it("normalizes gemini session params with cwd", () => {
+ const parsed = geminiSessionCodec.deserialize({
+ session_id: "gemini-session-1",
+ cwd: "/tmp/gemini",
+ });
+ expect(parsed).toEqual({
+ sessionId: "gemini-session-1",
+ cwd: "/tmp/gemini",
+ });
+
+ const serialized = geminiSessionCodec.serialize(parsed);
+ expect(serialized).toEqual({
+ sessionId: "gemini-session-1",
+ cwd: "/tmp/gemini",
+ });
+ expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
+ });
});
describe("codex resume recovery detection", () => {
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
).toBe(false);
});
});
+
+describe("gemini resume recovery detection", () => {
+ it("detects unknown session errors from gemini output", () => {
+ expect(
+ isGeminiUnknownSessionError(
+ "",
+ "unknown session id abc",
+ ),
+ ).toBe(true);
+ expect(
+ isGeminiUnknownSessionError(
+ "",
+ "checkpoint latest not found",
+ ),
+ ).toBe(true);
+ expect(
+ isGeminiUnknownSessionError(
+ "{\"type\":\"result\",\"subtype\":\"success\"}",
+ "",
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts
new file mode 100644
index 00000000..d4170e31
--- /dev/null
+++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { testEnvironment } from "@paperclipai/adapter-gemini-local/server";
+
+async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise {
+ const commandPath = path.join(binDir, "gemini");
+ const script = `#!/usr/bin/env node
+const fs = require("node:fs");
+const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
+if (outPath) {
+ fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
+}
+console.log(JSON.stringify({
+ type: "assistant",
+ message: { content: [{ type: "output_text", text: "hello" }] },
+}));
+console.log(JSON.stringify({
+ type: "result",
+ subtype: "success",
+ result: "hello",
+}));
+`;
+ await fs.writeFile(commandPath, script, "utf8");
+ await fs.chmod(commandPath, 0o755);
+ return commandPath;
+}
+
+describe("gemini_local environment diagnostics", () => {
+ it("creates a missing working directory when cwd is absolute", async () => {
+ const cwd = path.join(
+ os.tmpdir(),
+ `paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ "workspace",
+ );
+
+ await fs.rm(path.dirname(cwd), { recursive: true, force: true });
+
+ const result = await testEnvironment({
+ companyId: "company-1",
+ adapterType: "gemini_local",
+ config: {
+ command: process.execPath,
+ cwd,
+ },
+ });
+
+ expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
+ expect(result.checks.some((check) => check.level === "error")).toBe(false);
+ const stats = await fs.stat(cwd);
+ expect(stats.isDirectory()).toBe(true);
+ await fs.rm(path.dirname(cwd), { recursive: true, force: true });
+ });
+
+ it("passes model and yolo flags to the hello probe", async () => {
+ const root = path.join(
+ os.tmpdir(),
+ `paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ );
+ const binDir = path.join(root, "bin");
+ const cwd = path.join(root, "workspace");
+ const argsCapturePath = path.join(root, "args.json");
+ await fs.mkdir(binDir, { recursive: true });
+ await writeFakeGeminiCommand(binDir, argsCapturePath);
+
+ const result = await testEnvironment({
+ companyId: "company-1",
+ adapterType: "gemini_local",
+ config: {
+ command: "gemini",
+ cwd,
+ model: "gemini-2.5-pro",
+ yolo: true,
+ env: {
+ GEMINI_API_KEY: "test-key",
+ PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
+ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
+ },
+ },
+ });
+
+ expect(result.status).not.toBe("fail");
+ const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
+ expect(args).toContain("--model");
+ expect(args).toContain("gemini-2.5-pro");
+ expect(args).toContain("--approval-mode");
+ expect(args).toContain("yolo");
+ await fs.rm(root, { recursive: true, force: true });
+ });
+});
diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts
new file mode 100644
index 00000000..41da0530
--- /dev/null
+++ b/server/src/__tests__/gemini-local-adapter.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it, vi } from "vitest";
+import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
+import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
+import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
+
+describe("gemini_local parser", () => {
+ it("extracts session, summary, usage, cost, and terminal error message", () => {
+ const stdout = [
+ JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
+ JSON.stringify({
+ type: "assistant",
+ message: {
+ content: [{ type: "output_text", text: "hello" }],
+ },
+ }),
+ JSON.stringify({
+ type: "result",
+ subtype: "success",
+ session_id: "gemini-session-1",
+ usage: {
+ promptTokenCount: 12,
+ cachedContentTokenCount: 3,
+ candidatesTokenCount: 7,
+ },
+ total_cost_usd: 0.00123,
+ result: "done",
+ }),
+ JSON.stringify({ type: "error", message: "model access denied" }),
+ ].join("\n");
+
+ const parsed = parseGeminiJsonl(stdout);
+ expect(parsed.sessionId).toBe("gemini-session-1");
+ expect(parsed.summary).toBe("hello");
+ expect(parsed.usage).toEqual({
+ inputTokens: 12,
+ cachedInputTokens: 3,
+ outputTokens: 7,
+ });
+ expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
+ expect(parsed.errorMessage).toBe("model access denied");
+ });
+});
+
+describe("gemini_local stale session detection", () => {
+ it("treats missing session messages as an unknown session error", () => {
+ expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
+ expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
+ });
+});
+
+describe("gemini_local ui stdout parser", () => {
+ it("parses assistant, thinking, and result events", () => {
+ const ts = "2026-03-08T00:00:00.000Z";
+
+ expect(
+ parseGeminiStdoutLine(
+ JSON.stringify({
+ type: "assistant",
+ message: {
+ content: [
+ { type: "output_text", text: "I checked the repo." },
+ { type: "thinking", text: "Reviewing adapter registry" },
+ { type: "tool_call", name: "shell", input: { command: "ls -1" } },
+ { type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
+ ],
+ },
+ }),
+ ts,
+ ),
+ ).toEqual([
+ { kind: "assistant", ts, text: "I checked the repo." },
+ { kind: "thinking", ts, text: "Reviewing adapter registry" },
+ { kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
+ { kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
+ ]);
+
+ expect(
+ parseGeminiStdoutLine(
+ JSON.stringify({
+ type: "result",
+ subtype: "success",
+ result: "Done",
+ usage: {
+ promptTokenCount: 10,
+ candidatesTokenCount: 5,
+ cachedContentTokenCount: 2,
+ },
+ total_cost_usd: 0.00042,
+ is_error: false,
+ }),
+ ts,
+ ),
+ ).toEqual([
+ {
+ kind: "result",
+ ts,
+ text: "Done",
+ inputTokens: 10,
+ outputTokens: 5,
+ cachedTokens: 2,
+ costUsd: 0.00042,
+ subtype: "success",
+ isError: false,
+ errors: [],
+ },
+ ]);
+ });
+});
+
+function stripAnsi(value: string): string {
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
+describe("gemini_local cli formatter", () => {
+ it("prints init, assistant, result, and error events", () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
+ let joined = "";
+
+ try {
+ printGeminiStreamEvent(
+ JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
+ false,
+ );
+ printGeminiStreamEvent(
+ JSON.stringify({
+ type: "assistant",
+ message: { content: [{ type: "output_text", text: "hello" }] },
+ }),
+ false,
+ );
+ printGeminiStreamEvent(
+ JSON.stringify({
+ type: "result",
+ subtype: "success",
+ usage: {
+ promptTokenCount: 10,
+ candidatesTokenCount: 5,
+ cachedContentTokenCount: 2,
+ },
+ total_cost_usd: 0.00042,
+ }),
+ false,
+ );
+ printGeminiStreamEvent(
+ JSON.stringify({ type: "error", message: "boom" }),
+ false,
+ );
+ joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
+ } finally {
+ spy.mockRestore();
+ }
+
+ expect(joined).toContain("Gemini init");
+ expect(joined).toContain("assistant: hello");
+ expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
+ expect(joined).toContain("error: boom");
+ });
+});
diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts
new file mode 100644
index 00000000..92f8779a
--- /dev/null
+++ b/server/src/__tests__/gemini-local-execute.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { execute } from "@paperclipai/adapter-gemini-local/server";
+
+async function writeFakeGeminiCommand(commandPath: string): Promise {
+ const script = `#!/usr/bin/env node
+const fs = require("node:fs");
+
+const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
+const payload = {
+ argv: process.argv.slice(2),
+ paperclipEnvKeys: Object.keys(process.env)
+ .filter((key) => key.startsWith("PAPERCLIP_"))
+ .sort(),
+};
+if (capturePath) {
+ fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
+}
+console.log(JSON.stringify({
+ type: "system",
+ subtype: "init",
+ session_id: "gemini-session-1",
+ model: "gemini-2.5-pro",
+}));
+console.log(JSON.stringify({
+ type: "assistant",
+ message: { content: [{ type: "output_text", text: "hello" }] },
+}));
+console.log(JSON.stringify({
+ type: "result",
+ subtype: "success",
+ session_id: "gemini-session-1",
+ result: "ok",
+}));
+`;
+ await fs.writeFile(commandPath, script, "utf8");
+ await fs.chmod(commandPath, 0o755);
+}
+
+type CapturePayload = {
+ argv: string[];
+ paperclipEnvKeys: string[];
+};
+
+describe("gemini execute", () => {
+ it("passes prompt as final argument and injects paperclip env vars", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
+ const workspace = path.join(root, "workspace");
+ const commandPath = path.join(root, "gemini");
+ const capturePath = path.join(root, "capture.json");
+ await fs.mkdir(workspace, { recursive: true });
+ await writeFakeGeminiCommand(commandPath);
+
+ const previousHome = process.env.HOME;
+ process.env.HOME = root;
+
+ let invocationPrompt = "";
+ try {
+ const result = await execute({
+ runId: "run-1",
+ agent: {
+ id: "agent-1",
+ companyId: "company-1",
+ name: "Gemini Coder",
+ adapterType: "gemini_local",
+ adapterConfig: {},
+ },
+ runtime: {
+ sessionId: null,
+ sessionParams: null,
+ sessionDisplayId: null,
+ taskKey: null,
+ },
+ config: {
+ command: commandPath,
+ cwd: workspace,
+ model: "gemini-2.5-pro",
+ yolo: true,
+ env: {
+ PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
+ },
+ promptTemplate: "Follow the paperclip heartbeat.",
+ },
+ context: {},
+ authToken: "run-jwt-token",
+ onLog: async () => {},
+ onMeta: async (meta) => {
+ invocationPrompt = meta.prompt ?? "";
+ },
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.errorMessage).toBeNull();
+
+ const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
+ expect(capture.argv).toContain("--output-format");
+ expect(capture.argv).toContain("stream-json");
+ expect(capture.argv).toContain("--approval-mode");
+ expect(capture.argv).toContain("yolo");
+ expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
+ expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
+ expect(capture.paperclipEnvKeys).toEqual(
+ expect.arrayContaining([
+ "PAPERCLIP_AGENT_ID",
+ "PAPERCLIP_API_KEY",
+ "PAPERCLIP_API_URL",
+ "PAPERCLIP_COMPANY_ID",
+ "PAPERCLIP_RUN_ID",
+ ]),
+ );
+ expect(invocationPrompt).toContain("Paperclip runtime note:");
+ expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = previousHome;
+ }
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts
index 9fe536a0..14cdf6d9 100644
--- a/server/src/adapters/registry.ts
+++ b/server/src/adapters/registry.ts
@@ -17,6 +17,12 @@ import {
sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
+import {
+ execute as geminiExecute,
+ testEnvironment as geminiTestEnvironment,
+ sessionCodec as geminiSessionCodec,
+} from "@paperclipai/adapter-gemini-local/server";
+import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
import {
execute as openCodeExecute,
testEnvironment as openCodeTestEnvironment,
@@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: cursorAgentConfigurationDoc,
};
+const geminiLocalAdapter: ServerAdapterModule = {
+ type: "gemini_local",
+ execute: geminiExecute,
+ testEnvironment: geminiTestEnvironment,
+ sessionCodec: geminiSessionCodec,
+ models: geminiModels,
+ supportsLocalAgentJwt: true,
+ agentConfigurationDoc: geminiAgentConfigurationDoc,
+};
+
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
@@ -118,6 +134,7 @@ const adaptersByType = new Map(
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
+ geminiLocalAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index c27b893a..91a47d95 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -37,12 +37,14 @@ import {
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
+ gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
};
@@ -232,6 +234,10 @@ export function agentRoutes(db: Db) {
}
return ensureGatewayDeviceKey(adapterType, next);
}
+ if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
+ next.model = DEFAULT_GEMINI_LOCAL_MODEL;
+ return ensureGatewayDeviceKey(adapterType, next);
+ }
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index ac6de363..06928f08 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record
+
+
+
+ isCreate
+ ? set!({ instructionsFilePath: v })
+ : mark("adapterConfig", "instructionsFilePath", v || undefined)
+ }
+ immediate
+ className={inputClass}
+ placeholder="/absolute/path/to/AGENTS.md"
+ />
+
+
+
+
+ isCreate
+ ? set!({ dangerouslyBypassSandbox: v })
+ : mark("adapterConfig", "yolo", v)
+ }
+ />
+ >
+ );
+}
diff --git a/ui/src/adapters/gemini-local/index.ts b/ui/src/adapters/gemini-local/index.ts
new file mode 100644
index 00000000..d4cb89e5
--- /dev/null
+++ b/ui/src/adapters/gemini-local/index.ts
@@ -0,0 +1,12 @@
+import type { UIAdapterModule } from "../types";
+import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
+import { GeminiLocalConfigFields } from "./config-fields";
+import { buildGeminiLocalConfig } from "@paperclipai/adapter-gemini-local/ui";
+
+export const geminiLocalUIAdapter: UIAdapterModule = {
+ type: "gemini_local",
+ label: "Gemini CLI (local)",
+ parseStdoutLine: parseGeminiStdoutLine,
+ ConfigFields: GeminiLocalConfigFields,
+ buildAdapterConfig: buildGeminiLocalConfig,
+};
diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts
index 1a36af6b..d8c46738 100644
--- a/ui/src/adapters/registry.ts
+++ b/ui/src/adapters/registry.ts
@@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types";
import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
+import { geminiLocalUIAdapter } from "./gemini-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
@@ -12,6 +13,7 @@ const adaptersByType = new Map(
[
claudeLocalUIAdapter,
codexLocalUIAdapter,
+ geminiLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx
index 103a3cb4..5f92a588 100644
--- a/ui/src/components/AgentConfigForm.tsx
+++ b/ui/src/components/AgentConfigForm.tsx
@@ -16,6 +16,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import {
Popover,
PopoverContent,
@@ -282,6 +283,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const isLocal =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
+ adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "cursor";
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@@ -374,9 +376,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
- : adapterType === "opencode_local"
- ? eff("adapterConfig", "variant", String(config.variant ?? ""))
+ : adapterType === "opencode_local"
+ ? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
+ const showThinkingEffort = adapterType !== "gemini_local";
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
@@ -494,6 +497,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
+ } else if (t === "gemini_local") {
+ nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
@@ -510,6 +515,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
model:
t === "codex_local"
? DEFAULT_CODEX_LOCAL_MODEL
+ : t === "gemini_local"
+ ? DEFAULT_GEMINI_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: "",
@@ -615,6 +622,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
placeholder={
adapterType === "codex_local"
? "codex"
+ : adapterType === "gemini_local"
+ ? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
@@ -646,24 +655,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
-
- isCreate
- ? set!({ thinkingEffort: v })
- : mark("adapterConfig", thinkingEffortKey, v || undefined)
- }
- open={thinkingEffortOpen}
- onOpenChange={setThinkingEffortOpen}
- />
- {adapterType === "codex_local" &&
- codexSearchEnabled &&
- currentThinkingEffort === "minimal" && (
-
- Codex may reject `minimal` thinking when search is enabled.
-
- )}
+ {showThinkingEffort && (
+ <>
+
+ isCreate
+ ? set!({ thinkingEffort: v })
+ : mark("adapterConfig", thinkingEffortKey, v || undefined)
+ }
+ open={thinkingEffortOpen}
+ onOpenChange={setThinkingEffortOpen}
+ />
+ {adapterType === "codex_local" &&
+ codexSearchEnabled &&
+ currentThinkingEffort === "minimal" && (
+
+ Codex may reject `minimal` thinking when search is enabled.
+
+ )}
+ >
+ )}
= {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
+ gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx
index 18830792..15114bf7 100644
--- a/ui/src/components/NewAgentDialog.tsx
+++ b/ui/src/components/NewAgentDialog.tsx
@@ -14,6 +14,7 @@ import {
ArrowLeft,
Bot,
Code,
+ Gem,
MousePointer2,
Sparkles,
Terminal,
@@ -24,6 +25,7 @@ import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
type AdvancedAdapterType =
| "claude_local"
| "codex_local"
+ | "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
@@ -50,6 +52,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
desc: "Local Codex agent",
recommended: true,
},
+ {
+ value: "gemini_local",
+ label: "Gemini CLI",
+ icon: Gem,
+ desc: "Local Gemini agent",
+ },
{
value: "opencode_local",
label: "OpenCode",
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index 5451e278..a043655f 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -25,6 +25,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
@@ -33,6 +34,7 @@ import {
Building2,
Bot,
Code,
+ Gem,
ListTodo,
Rocket,
ArrowLeft,
@@ -51,6 +53,7 @@ type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
+ | "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
@@ -165,11 +168,17 @@ export function OnboardingWizard() {
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
});
const isLocalAdapter =
- adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
+ adapterType === "claude_local" ||
+ adapterType === "codex_local" ||
+ adapterType === "gemini_local" ||
+ adapterType === "opencode_local" ||
+ adapterType === "cursor";
const effectiveAdapterCommand =
command.trim() ||
(adapterType === "codex_local"
? "codex"
+ : adapterType === "gemini_local"
+ ? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
@@ -268,6 +277,8 @@ export function OnboardingWizard() {
model:
adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL
+ : adapterType === "gemini_local"
+ ? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL
: model,
@@ -655,6 +666,12 @@ export function OnboardingWizard() {
desc: "Local Codex agent",
recommended: true
},
+ {
+ value: "gemini_local" as const,
+ label: "Gemini CLI",
+ icon: Gem,
+ desc: "Local Gemini agent"
+ },
{
value: "opencode_local" as const,
label: "OpenCode",
@@ -699,6 +716,8 @@ export function OnboardingWizard() {
setAdapterType(nextType);
if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL);
+ } else if (nextType === "gemini_local" && !model) {
+ setModel(DEFAULT_GEMINI_LOCAL_MODEL);
} else if (nextType === "cursor" && !model) {
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
}
@@ -732,6 +751,7 @@ export function OnboardingWizard() {
{/* Conditional adapter fields */}
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
+ adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
@@ -904,6 +924,8 @@ export function OnboardingWizard() {
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
: adapterType === "codex_local"
? `${effectiveAdapterCommand} exec --json -`
+ : adapterType === "gemini_local"
+ ? `${effectiveAdapterCommand} --output-format json \"Respond with hello.\"`
: adapterType === "opencode_local"
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
@@ -912,11 +934,15 @@ export function OnboardingWizard() {
Prompt:{" "}
Respond with hello.
- {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "opencode_local" ? (
+ {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" ? (
If auth fails, set{" "}
- {adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"}
+ {adapterType === "cursor"
+ ? "CURSOR_API_KEY"
+ : adapterType === "gemini_local"
+ ? "GEMINI_API_KEY"
+ : "OPENAI_API_KEY"}
{" "}
in
env or run{" "}
@@ -925,6 +951,8 @@ export function OnboardingWizard() {
? "agent login"
: adapterType === "codex_local"
? "codex login"
+ : adapterType === "gemini_local"
+ ? "gemini auth"
: "opencode auth login"}
.
diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx
index 2d26753d..77a5b14c 100644
--- a/ui/src/components/agent-config-primitives.tsx
+++ b/ui/src/components/agent-config-primitives.tsx
@@ -60,6 +60,7 @@ export const help: Record = {
export const adapterLabels: Record = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
+ gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx
index fbae126d..741d8449 100644
--- a/ui/src/pages/Agents.tsx
+++ b/ui/src/pages/Agents.tsx
@@ -23,6 +23,7 @@ import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
const adapterLabels: Record = {
claude_local: "Claude",
codex_local: "Codex",
+ gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw_gateway: "OpenClaw Gateway",
diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx
index ada19fd5..571cf739 100644
--- a/ui/src/pages/InviteLanding.tsx
+++ b/ui/src/pages/InviteLanding.tsx
@@ -15,6 +15,7 @@ const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const adapterLabels: Record = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
+ gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
@@ -22,7 +23,7 @@ const adapterLabels: Record = {
http: "HTTP",
};
-const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
+const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
function dateTime(value: string) {
return new Date(value).toLocaleString();
diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx
index b28d9710..364e35a0 100644
--- a/ui/src/pages/NewAgent.tsx
+++ b/ui/src/pages/NewAgent.tsx
@@ -24,10 +24,12 @@ import {
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set([
"claude_local",
"codex_local",
+ "gemini_local",
"opencode_local",
"pi_local",
"cursor",
@@ -43,6 +45,8 @@ function createValuesForAdapterType(
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
+ } else if (adapterType === "gemini_local") {
+ nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (adapterType === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (adapterType === "opencode_local") {
diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx
index 89b4d581..981545c0 100644
--- a/ui/src/pages/OrgChart.tsx
+++ b/ui/src/pages/OrgChart.tsx
@@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
const adapterLabels: Record = {
claude_local: "Claude",
codex_local: "Codex",
+ gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw_gateway: "OpenClaw Gateway",