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",