feat(adapters): add Gemini CLI local adapter support

Signed-off-by: Aditya Sasidhar <telikicherlaadityasasidhar@gmail.com>
This commit is contained in:
Aditya Sasidhar
2026-03-08 16:43:34 +05:30
committed by Aaron
parent 7934952a77
commit af97259a9c
40 changed files with 2183 additions and 11894 deletions

View File

@@ -37,6 +37,7 @@
"@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*",

View File

@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-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 { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printCursorStreamEvent, formatStdoutEvent: printCursorStreamEvent,
}; };
const geminiLocalCLIAdapter: CLIAdapterModule = {
type: "gemini_local",
formatStdoutEvent: printGeminiStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = { const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway", type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent, formatStdoutEvent: printOpenClawGatewayStreamEvent,
@@ -45,6 +51,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
openCodeLocalCLIAdapter, openCodeLocalCLIAdapter,
piLocalCLIAdapter, piLocalCLIAdapter,
cursorLocalCLIAdapter, cursorLocalCLIAdapter,
geminiLocalCLIAdapter,
openclawGatewayCLIAdapter, openclawGatewayCLIAdapter,
processCLIAdapter, processCLIAdapter,
httpCLIAdapter, httpCLIAdapter,

View File

@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
auth, auth,
}; };
} }

View File

@@ -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

View File

@@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------| |---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex 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`) | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
@@ -54,7 +55,7 @@ Three registries consume these modules:
## Choosing an Adapter ## 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 run a script or command?** Use `process`
- **Need to call an external service?** Use `http` - **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)

View File

@@ -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"
}
}

View File

@@ -0,0 +1,208 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<string, unknown>) {
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<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} 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);
}

View File

@@ -0,0 +1 @@
export { printGeminiStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,45 @@
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" },
];
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 available under the Gemini global skills directory
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.
- yolo (boolean, optional): pass --approval-mode yolo for unattended operation
- 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" when missing.
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
`;

View File

@@ -0,0 +1,405 @@
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 { isGeminiUnknownSessionError, parseGeminiJsonl } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api"
: "subscription";
}
function renderPaperclipEnvNote(env: Record<string, string>): 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");
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
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;
}
type EnsureGeminiSkillsInjectedOptions = {
skillsDir?: string | null;
skillsHome?: string;
linkSkill?: (source: string, target: string) => Promise<void>;
};
export async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureGeminiSkillsInjectedOptions = {},
) {
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsHome = options.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;
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
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 linkSkill(source, target);
await onLog(
"stderr",
`[paperclip] Injected Gemini skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Gemini skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
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 yolo = asBoolean(config.yolo, 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<string, unknown> => 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<string, string> = { ...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 (yolo) notes.push("Added --approval-mode yolo 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 (yolo) args.push("--approval-mode", "yolo");
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 ? `<prompt ${prompt.length} chars>` : 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<typeof parseGeminiJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const fallbackErrorMessage =
parsedError ||
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,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "google",
model,
billingType,
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: 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);
}
return toResult(initial);
}

View File

@@ -0,0 +1,64 @@
export { execute, ensureGeminiSkillsInjected } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseGeminiJsonl, isGeminiUnknownSessionError } 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<string, unknown>;
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<string, unknown> | 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<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};

View File

@@ -0,0 +1,167 @@
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, unknown>): 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;
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") {
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,
};
}
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,
);
}

View File

@@ -0,0 +1,220 @@
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 { parseGeminiJsonl } from "./parse.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 firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
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;
}
const GEMINI_AUTH_REQUIRED_RE =
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|login\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|gemini[_\s-]?api[_\s-]?key|google[_\s-]?api[_\s-]?key|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
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<string, string> = {};
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;
if (
isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) ||
isNonEmpty(configGoogleApiKey) ||
isNonEmpty(hostGoogleApiKey)
) {
const source = 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: "warn",
message: "No Gemini API key was detected. Gemini runs may fail until auth is configured.",
hint: "Set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env/shell, or run `gemini auth` / `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 yolo = asBoolean(config.yolo, false);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["--output-format", "json"];
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (yolo) args.push("--approval-mode", "yolo");
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 authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
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 (GEMINI_AUTH_REQUIRED_RE.test(authEvidence)) {
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(),
};
}

View File

@@ -0,0 +1,74 @@
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<string, string> {
const env: Record<string, string> = {};
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<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
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<string, unknown>;
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<string, unknown> {
const ac: Record<string, unknown> = {};
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.yolo = true;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parseGeminiStdoutLine } from "./parse-stdout.js";
export { buildGeminiLocalConfig } from "./build-config.js";

View File

@@ -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<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<string, unknown>, 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, unknown>): string {
return (
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID) ||
asString(parsed.checkpoint_id) ||
asString(parsed.thread_id)
);
}
function readUsage(parsed: Record<string, unknown>) {
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 }];
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
"http", "http",
"claude_local", "claude_local",
"codex_local", "codex_local",
"gemini_local",
"opencode_local", "opencode_local",
"pi_local", "pi_local",
"cursor", "cursor",

11867
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@
"@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*",

View File

@@ -5,6 +5,10 @@ import {
sessionCodec as cursorSessionCodec, sessionCodec as cursorSessionCodec,
isCursorUnknownSessionError, isCursorUnknownSessionError,
} from "@paperclipai/adapter-cursor-local/server"; } from "@paperclipai/adapter-cursor-local/server";
import {
sessionCodec as geminiSessionCodec,
isGeminiUnknownSessionError,
} from "@paperclipai/adapter-gemini-local/server";
import { import {
sessionCodec as opencodeSessionCodec, sessionCodec as opencodeSessionCodec,
isOpenCodeUnknownSessionError, isOpenCodeUnknownSessionError,
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
}); });
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1"); 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", () => { describe("codex resume recovery detection", () => {
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
).toBe(false); ).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);
});
});

View File

@@ -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<string> {
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 });
});
});

View File

@@ -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");
});
});

View File

@@ -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<void> {
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 });
}
});
});

View File

@@ -17,6 +17,12 @@ import {
sessionCodec as cursorSessionCodec, sessionCodec as cursorSessionCodec,
} from "@paperclipai/adapter-cursor-local/server"; } from "@paperclipai/adapter-cursor-local/server";
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; 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 { import {
execute as openCodeExecute, execute as openCodeExecute,
testEnvironment as openCodeTestEnvironment, testEnvironment as openCodeTestEnvironment,
@@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: cursorAgentConfigurationDoc, agentConfigurationDoc: cursorAgentConfigurationDoc,
}; };
const geminiLocalAdapter: ServerAdapterModule = {
type: "gemini_local",
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
sessionCodec: geminiSessionCodec,
models: geminiModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: geminiAgentConfigurationDoc,
};
const openclawGatewayAdapter: ServerAdapterModule = { const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway", type: "openclaw_gateway",
execute: openclawGatewayExecute, execute: openclawGatewayExecute,
@@ -118,6 +134,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
openCodeLocalAdapter, openCodeLocalAdapter,
piLocalAdapter, piLocalAdapter,
cursorLocalAdapter, cursorLocalAdapter,
geminiLocalAdapter,
openclawGatewayAdapter, openclawGatewayAdapter,
processAdapter, processAdapter,
httpAdapter, httpAdapter,

View File

@@ -37,12 +37,14 @@ import {
DEFAULT_CODEX_LOCAL_MODEL, DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local"; } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-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"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) { export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath", claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath", codex_local: "instructionsFilePath",
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath", opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath", cursor: "instructionsFilePath",
}; };
@@ -232,6 +234,10 @@ export function agentRoutes(db: Db) {
} }
return ensureGatewayDeviceKey(adapterType, next); 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 // OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) { if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL; next.model = DEFAULT_CURSOR_LOCAL_MODEL;

View File

@@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["timeoutSec"], value: 0 }, { path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 }, { path: ["graceSec"], value: 15 },
], ],
gemini_local: [
{ path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 },
],
opencode_local: [ opencode_local: [
{ path: ["timeoutSec"], value: 0 }, { path: ["timeoutSec"], value: 0 },
{ path: ["graceSec"], value: 15 }, { path: ["graceSec"], value: 15 },

View File

@@ -17,6 +17,7 @@
"@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*",

View File

@@ -0,0 +1,64 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
DraftInput,
Field,
ToggleField,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Gemini prompt at runtime.";
export function GeminiLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
return (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
<ToggleField
label="Yolo mode"
hint="Run Gemini with --approval-mode yolo for unattended operation."
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff("adapterConfig", "yolo", config.yolo === true)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "yolo", v)
}
/>
</>
);
}

View File

@@ -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,
};

View File

@@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types";
import { claudeLocalUIAdapter } from "./claude-local"; import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local"; import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor"; import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { openCodeLocalUIAdapter } from "./opencode-local"; import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local"; import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { openClawGatewayUIAdapter } from "./openclaw-gateway";
@@ -12,6 +13,7 @@ const adaptersByType = new Map<string, UIAdapterModule>(
[ [
claudeLocalUIAdapter, claudeLocalUIAdapter,
codexLocalUIAdapter, codexLocalUIAdapter,
geminiLocalUIAdapter,
openCodeLocalUIAdapter, openCodeLocalUIAdapter,
piLocalUIAdapter, piLocalUIAdapter,
cursorLocalUIAdapter, cursorLocalUIAdapter,

View File

@@ -16,6 +16,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL, DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local"; } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -275,6 +276,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const isLocal = const isLocal =
adapterType === "claude_local" || adapterType === "claude_local" ||
adapterType === "codex_local" || adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" || adapterType === "opencode_local" ||
adapterType === "cursor"; adapterType === "cursor";
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@@ -367,9 +369,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) )
: adapterType === "cursor" : adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? "")) ? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local" : adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? "")) ? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? "")); : eff("adapterConfig", "effort", String(config.effort ?? ""));
const showThinkingEffort = adapterType !== "gemini_local";
const codexSearchEnabled = adapterType === "codex_local" const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false; : false;
@@ -487,6 +490,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox = nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (t === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "cursor") { } else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") { } else if (t === "opencode_local") {
@@ -503,6 +508,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
model: model:
t === "codex_local" t === "codex_local"
? DEFAULT_CODEX_LOCAL_MODEL ? DEFAULT_CODEX_LOCAL_MODEL
: t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
: t === "cursor" : t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL ? DEFAULT_CURSOR_LOCAL_MODEL
: "", : "",
@@ -608,6 +615,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
placeholder={ placeholder={
adapterType === "codex_local" adapterType === "codex_local"
? "codex" ? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor" : adapterType === "cursor"
? "agent" ? "agent"
: adapterType === "opencode_local" : adapterType === "opencode_local"
@@ -639,24 +648,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</p> </p>
)} )}
<ThinkingEffortDropdown {showThinkingEffort && (
value={currentThinkingEffort} <>
options={thinkingEffortOptions} <ThinkingEffortDropdown
onChange={(v) => value={currentThinkingEffort}
isCreate options={thinkingEffortOptions}
? set!({ thinkingEffort: v }) onChange={(v) =>
: mark("adapterConfig", thinkingEffortKey, v || undefined) isCreate
} ? set!({ thinkingEffort: v })
open={thinkingEffortOpen} : mark("adapterConfig", thinkingEffortKey, v || undefined)
onOpenChange={setThinkingEffortOpen} }
/> open={thinkingEffortOpen}
{adapterType === "codex_local" && onOpenChange={setThinkingEffortOpen}
codexSearchEnabled && />
currentThinkingEffort === "minimal" && ( {adapterType === "codex_local" &&
<p className="text-xs text-amber-400"> codexSearchEnabled &&
Codex may reject `minimal` thinking when search is enabled. currentThinkingEffort === "minimal" && (
</p> <p className="text-xs text-amber-400">
)} Codex may reject `minimal` thinking when search is enabled.
</p>
)}
</>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}> <Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<MarkdownEditor <MarkdownEditor
value={ value={
@@ -891,7 +904,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */ /* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]); const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */ /** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [

View File

@@ -17,6 +17,7 @@ interface AgentPropertiesProps {
const adapterLabels: Record<string, string> = { const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)", claude_local: "Claude (local)",
codex_local: "Codex (local)", codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)", opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)", cursor: "Cursor (local)",

View File

@@ -14,6 +14,7 @@ import {
ArrowLeft, ArrowLeft,
Bot, Bot,
Code, Code,
Gem,
MousePointer2, MousePointer2,
Sparkles, Sparkles,
Terminal, Terminal,
@@ -24,6 +25,7 @@ import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
type AdvancedAdapterType = type AdvancedAdapterType =
| "claude_local" | "claude_local"
| "codex_local" | "codex_local"
| "gemini_local"
| "opencode_local" | "opencode_local"
| "pi_local" | "pi_local"
| "cursor" | "cursor"
@@ -50,6 +52,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
desc: "Local Codex agent", desc: "Local Codex agent",
recommended: true, recommended: true,
}, },
{
value: "gemini_local",
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent",
},
{ {
value: "opencode_local", value: "opencode_local",
label: "OpenCode", label: "OpenCode",

View File

@@ -25,6 +25,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL DEFAULT_CODEX_LOCAL_MODEL
} from "@paperclipai/adapter-codex-local"; } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-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 { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives"; import { HintIcon } from "./agent-config-primitives";
@@ -33,6 +34,7 @@ import {
Building2, Building2,
Bot, Bot,
Code, Code,
Gem,
ListTodo, ListTodo,
Rocket, Rocket,
ArrowLeft, ArrowLeft,
@@ -51,6 +53,7 @@ type Step = 1 | 2 | 3 | 4;
type AdapterType = type AdapterType =
| "claude_local" | "claude_local"
| "codex_local" | "codex_local"
| "gemini_local"
| "opencode_local" | "opencode_local"
| "pi_local" | "pi_local"
| "cursor" | "cursor"
@@ -165,11 +168,17 @@ export function OnboardingWizard() {
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
}); });
const isLocalAdapter = 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 = const effectiveAdapterCommand =
command.trim() || command.trim() ||
(adapterType === "codex_local" (adapterType === "codex_local"
? "codex" ? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor" : adapterType === "cursor"
? "agent" ? "agent"
: adapterType === "opencode_local" : adapterType === "opencode_local"
@@ -268,6 +277,8 @@ export function OnboardingWizard() {
model: model:
adapterType === "codex_local" adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL ? model || DEFAULT_CODEX_LOCAL_MODEL
: adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor" : adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL ? model || DEFAULT_CURSOR_LOCAL_MODEL
: model, : model,
@@ -655,6 +666,12 @@ export function OnboardingWizard() {
desc: "Local Codex agent", desc: "Local Codex agent",
recommended: true recommended: true
}, },
{
value: "gemini_local" as const,
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent"
},
{ {
value: "opencode_local" as const, value: "opencode_local" as const,
label: "OpenCode", label: "OpenCode",
@@ -699,6 +716,8 @@ export function OnboardingWizard() {
setAdapterType(nextType); setAdapterType(nextType);
if (nextType === "codex_local" && !model) { if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL); setModel(DEFAULT_CODEX_LOCAL_MODEL);
} else if (nextType === "gemini_local" && !model) {
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
} else if (nextType === "cursor" && !model) { } else if (nextType === "cursor" && !model) {
setModel(DEFAULT_CURSOR_LOCAL_MODEL); setModel(DEFAULT_CURSOR_LOCAL_MODEL);
} }
@@ -732,6 +751,7 @@ export function OnboardingWizard() {
{/* Conditional adapter fields */} {/* Conditional adapter fields */}
{(adapterType === "claude_local" || {(adapterType === "claude_local" ||
adapterType === "codex_local" || adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" || adapterType === "opencode_local" ||
adapterType === "pi_local" || adapterType === "pi_local" ||
adapterType === "cursor") && ( adapterType === "cursor") && (
@@ -904,6 +924,8 @@ export function OnboardingWizard() {
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"` ? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
: adapterType === "codex_local" : adapterType === "codex_local"
? `${effectiveAdapterCommand} exec --json -` ? `${effectiveAdapterCommand} exec --json -`
: adapterType === "gemini_local"
? `${effectiveAdapterCommand} --output-format json \"Respond with hello.\"`
: adapterType === "opencode_local" : adapterType === "opencode_local"
? `${effectiveAdapterCommand} run --format json "Respond with hello."` ? `${effectiveAdapterCommand} run --format json "Respond with hello."`
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`} : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
@@ -912,11 +934,15 @@ export function OnboardingWizard() {
Prompt:{" "} Prompt:{" "}
<span className="font-mono">Respond with hello.</span> <span className="font-mono">Respond with hello.</span>
</p> </p>
{adapterType === "cursor" || adapterType === "codex_local" || adapterType === "opencode_local" ? ( {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
If auth fails, set{" "} If auth fails, set{" "}
<span className="font-mono"> <span className="font-mono">
{adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"} {adapterType === "cursor"
? "CURSOR_API_KEY"
: adapterType === "gemini_local"
? "GEMINI_API_KEY"
: "OPENAI_API_KEY"}
</span>{" "} </span>{" "}
in in
env or run{" "} env or run{" "}
@@ -925,6 +951,8 @@ export function OnboardingWizard() {
? "agent login" ? "agent login"
: adapterType === "codex_local" : adapterType === "codex_local"
? "codex login" ? "codex login"
: adapterType === "gemini_local"
? "gemini auth"
: "opencode auth login"} : "opencode auth login"}
</span>. </span>.
</p> </p>

View File

@@ -53,6 +53,7 @@ export const help: Record<string, string> = {
export const adapterLabels: Record<string, string> = { export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)", claude_local: "Claude (local)",
codex_local: "Codex (local)", codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)", opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)", cursor: "Cursor (local)",

View File

@@ -23,6 +23,7 @@ import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
const adapterLabels: Record<string, string> = { const adapterLabels: Record<string, string> = {
claude_local: "Claude", claude_local: "Claude",
codex_local: "Codex", codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode", opencode_local: "OpenCode",
cursor: "Cursor", cursor: "Cursor",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",

View File

@@ -15,6 +15,7 @@ const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const adapterLabels: Record<string, string> = { const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)", claude_local: "Claude (local)",
codex_local: "Codex (local)", codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)", opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)", cursor: "Cursor (local)",
@@ -22,7 +23,7 @@ const adapterLabels: Record<string, string> = {
http: "HTTP", 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) { function dateTime(value: string) {
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();

View File

@@ -24,10 +24,12 @@ import {
DEFAULT_CODEX_LOCAL_MODEL, DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local"; } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-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<CreateConfigValues["adapterType"]>([ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
"claude_local", "claude_local",
"codex_local", "codex_local",
"gemini_local",
"opencode_local", "opencode_local",
"pi_local", "pi_local",
"cursor", "cursor",
@@ -43,6 +45,8 @@ function createValuesForAdapterType(
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox = nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (adapterType === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (adapterType === "cursor") { } else if (adapterType === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (adapterType === "opencode_local") { } else if (adapterType === "opencode_local") {

View File

@@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
const adapterLabels: Record<string, string> = { const adapterLabels: Record<string, string> = {
claude_local: "Claude", claude_local: "Claude",
codex_local: "Codex", codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode", opencode_local: "OpenCode",
cursor: "Cursor", cursor: "Cursor",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",