Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
42 KiB
Gemini CLI Adapter Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a full-featured Gemini CLI adapter to code-tap, providing bidirectional mobile control identical to the existing Claude and Codex adapters.
Architecture: Pluggable adapter extending IAdapter with three event channels (HTTP hooks via bridge script, JSON file watcher, tmux pane monitor). Shared tmux-manager.ts extracted to server/adapters/shared/. New JsonWatcher for Gemini's single-JSON session format.
Tech Stack: TypeScript, Express, fs.watch, tmux, bash (bridge script)
Spec: docs/superpowers/specs/2026-03-26-gemini-adapter-design.md
Note: This project has no test runner configured. Steps marked "verify" use manual checks (npm run dev + curl). Unit tests are noted as follow-up.
Task 1: Shared Layer — Move tmux-manager.ts
Files:
-
Create:
server/adapters/shared/tmux-manager.ts(copy from claude/) -
Delete:
server/adapters/claude/tmux-manager.ts -
Modify:
server/adapters/claude/tmux-adapter.ts(import path) -
Modify:
server/adapters/claude/pane-monitor.ts(import path) -
Modify:
server/adapters/codex/codex-tmux-adapter.ts(import path) -
Step 1: Create shared/ directory and move the file
mkdir -p server/adapters/shared
git mv server/adapters/claude/tmux-manager.ts server/adapters/shared/tmux-manager.ts
- Step 2: Update import in
server/adapters/claude/tmux-adapter.ts
Change:
import { tmuxManager } from './tmux-manager.js';
import type { TmuxWindow } from './tmux-manager.js';
To:
import { tmuxManager } from '../shared/tmux-manager.js';
import type { TmuxWindow } from '../shared/tmux-manager.js';
- Step 3: Update import in
server/adapters/claude/pane-monitor.ts
Change:
import { tmuxManager } from './tmux-manager.js';
To:
import { tmuxManager } from '../shared/tmux-manager.js';
- Step 4: Update import in
server/adapters/codex/codex-tmux-adapter.ts
Change:
import { tmuxManager } from '../claude/tmux-manager.js';
To:
import { tmuxManager } from '../shared/tmux-manager.js';
- Step 5: Verify — server starts without errors
npx tsx server/index.ts
Expected: Server starts, no import errors. Ctrl+C to stop.
- Step 6: Commit
git add -A && git commit -m "refactor: move tmux-manager.ts to shared/"
Task 2: JsonWatcher — New File Watcher for JSON Sessions
Files:
-
Create:
server/stores/json-watcher.ts -
Step 1: Create
server/stores/json-watcher.ts
// server/stores/json-watcher.ts
//
// Watches a single JSON session file for new messages.
// Unlike JsonlWatcher (byte-offset for append-only JSONL), this handles
// Gemini's single-JSON format where the entire file is rewritten on each update.
//
// Strategy: fs.watch + stat() size guard + message count/ID tracking + debounce.
import fs from 'fs';
/** A single message from a Gemini session JSON file */
export interface GeminiSessionMessage {
id: string;
timestamp: string;
type: 'user' | 'gemini' | 'error' | 'info';
content: unknown;
thoughts?: unknown[];
tokens?: Record<string, number>;
model?: string;
toolCalls?: unknown[];
}
/** Top-level structure of a Gemini session JSON file */
interface GeminiSessionFile {
sessionId: string;
projectHash?: string;
startTime: string;
lastUpdated: string;
messages: GeminiSessionMessage[];
kind?: string;
summary?: string;
}
export interface JsonWatcherStartOptions {
skipExisting?: boolean;
fallbackIntervalMs?: number;
debounceMs?: number;
}
const SIZE_WARNING_THRESHOLD = 2 * 1024 * 1024; // 2MB
export class JsonWatcher {
filePath: string;
private _lastSize: number = 0;
private _lastMessageCount: number = 0;
private _lastMessageId: string | null = null;
private _fsWatcher: fs.FSWatcher | null = null;
private _fallbackInterval: ReturnType<typeof setInterval> | null = null;
private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
private _debounceMs: number = 50;
private _polling: boolean = false;
private _onMessages: ((messages: GeminiSessionMessage[]) => void) | null = null;
private _onError: ((err: Error) => void) | null = null;
constructor(filePath: string) {
this.filePath = filePath;
}
start({ skipExisting = true, fallbackIntervalMs = 2000, debounceMs = 50 }: JsonWatcherStartOptions = {}): void {
this._debounceMs = debounceMs;
if (skipExisting) {
// Read current state so we only emit future messages
try {
const content = fs.readFileSync(this.filePath, 'utf-8');
const session: GeminiSessionFile = JSON.parse(content);
this._lastSize = fs.statSync(this.filePath).size;
this._lastMessageCount = session.messages.length;
if (session.messages.length > 0) {
this._lastMessageId = session.messages[session.messages.length - 1]!.id;
}
} catch {}
}
// Primary: fs.watch for instant change notification
try {
this._fsWatcher = fs.watch(this.filePath, () => this._scheduleDebounce());
} catch {
// fs.watch may fail — fallback polling handles it
}
// Fallback: poll every N ms
this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs);
// Immediate first poll (catches messages if skipExisting=false)
if (!skipExisting) this._poll();
}
stop(): void {
if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; }
if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; }
if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; }
}
onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void { this._onMessages = cb; }
onError(cb: (err: Error) => void): void { this._onError = cb; }
/** Force an immediate poll (used by hooks to ensure latest state is read) */
pollNow(): void { this._poll(); }
/** Mark current file position — subsequent polls only return content after this point. */
markCurrentPosition(): void {
try {
const content = fs.readFileSync(this.filePath, 'utf-8');
const session: GeminiSessionFile = JSON.parse(content);
this._lastSize = fs.statSync(this.filePath).size;
this._lastMessageCount = session.messages.length;
if (session.messages.length > 0) {
this._lastMessageId = session.messages[session.messages.length - 1]!.id;
}
} catch {}
}
private _scheduleDebounce(): void {
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => this._poll(), this._debounceMs);
}
private _poll(): void {
if (this._polling) return;
this._polling = true;
try {
const stats = fs.statSync(this.filePath);
// File size unchanged — skip (filters fs.watch false positives)
if (stats.size === this._lastSize) {
this._polling = false;
return;
}
// Performance warning
if (stats.size > SIZE_WARNING_THRESHOLD) {
console.warn(`[json-watcher] Session file is ${(stats.size / 1024 / 1024).toFixed(1)}MB: ${this.filePath}`);
}
const content = fs.readFileSync(this.filePath, 'utf-8');
const session: GeminiSessionFile = JSON.parse(content);
const messages = session.messages;
// No new messages
if (messages.length <= this._lastMessageCount) {
// File was rewritten but no new messages (metadata update only)
this._lastSize = stats.size;
this._polling = false;
return;
}
// Verify continuity: if _lastMessageId is set, find its index
let startIndex = this._lastMessageCount;
if (this._lastMessageId) {
// Check if the message at our expected position still matches
const expectedMsg = messages[this._lastMessageCount - 1];
if (expectedMsg && expectedMsg.id !== this._lastMessageId) {
// Messages were reordered/deleted — find actual position
const foundIndex = messages.findIndex(m => m.id === this._lastMessageId);
startIndex = foundIndex >= 0 ? foundIndex + 1 : 0;
}
}
const newMessages = messages.slice(startIndex);
// Update tracking state
this._lastSize = stats.size;
this._lastMessageCount = messages.length;
if (messages.length > 0) {
this._lastMessageId = messages[messages.length - 1]!.id;
}
if (newMessages.length > 0 && this._onMessages) {
this._onMessages(newMessages);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) {
this._onError(err as Error);
}
} finally {
this._polling = false;
}
}
}
- Step 2: Verify — TypeScript compiles
npx tsc --noEmit server/stores/json-watcher.ts 2>&1 | head -20
Expected: No errors (or only errors from missing sibling imports, not from this file).
- Step 3: Commit
git add server/stores/json-watcher.ts && git commit -m "feat(gemini): add JsonWatcher for single-JSON session files"
Task 3: Gemini Types & Message Utils
Files:
-
Create:
server/adapters/gemini/message-utils.ts -
Step 1: Create
server/adapters/gemini/message-utils.ts
This file defines Gemini-specific types and content extraction helpers. Reference server/adapters/claude/message-utils.ts for the ContentBlock type — import it from there or from server/types/messages.ts.
// server/adapters/gemini/message-utils.ts
//
// Gemini-specific types and content extraction helpers.
// Converts Gemini's JSON message format to the shared ContentBlock format.
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
// Import ContentBlock from Claude's message-utils (shared type used by all adapters).
// Do NOT re-declare — use the canonical definition to avoid type divergence.
import type { ContentBlock } from '../claude/message-utils.js';
/** Gemini tool call from session JSON */
export interface GeminiToolCall {
id: string;
name: string;
args: Record<string, unknown>;
result?: Array<{
functionResponse: {
id: string;
name: string;
response: { output?: string; error?: string };
};
}>;
status: 'success' | 'cancelled' | 'error';
timestamp: string;
displayName?: string;
description?: string;
renderOutputAsMarkdown?: boolean;
}
/** Extract plain text from Gemini user message content */
export function extractUserText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((c: any) => c && typeof c.text === 'string')
.map((c: any) => c.text)
.join('\n');
}
return '';
}
/** Extract plain text from Gemini assistant message content */
export function extractGeminiText(content: unknown): string {
if (typeof content === 'string') return content;
return '';
}
/** Convert Gemini toolCalls to ContentBlock[] (tool_use + tool_result pairs) */
export function toolCallsToContentBlocks(toolCalls: GeminiToolCall[]): ContentBlock[] {
const blocks: ContentBlock[] = [];
for (const tc of toolCalls) {
// tool_use block
blocks.push({
type: 'tool_use',
id: tc.id,
name: tc.name,
input: tc.args,
});
// tool_result block (if result exists)
if (tc.result && tc.result.length > 0) {
const resp = tc.result[0]!.functionResponse.response;
blocks.push({
type: 'tool_result',
tool_use_id: tc.id,
content: resp.output || resp.error || '',
is_error: tc.status === 'error' || tc.status === 'cancelled' || !!resp.error,
});
}
}
return blocks;
}
- Step 2: Commit
git add server/adapters/gemini/message-utils.ts && git commit -m "feat(gemini): add message-utils with types and content extraction"
Task 4: Transcript Parser
Files:
-
Create:
server/adapters/gemini/transcript-parser.ts -
Step 1: Create
server/adapters/gemini/transcript-parser.ts
Reference server/adapters/claude/transcript-parser.ts for the ParsedMessage / ParseResult types. The Gemini parser is simpler because tool calls and thinking are embedded in the message JSON.
// server/adapters/gemini/transcript-parser.ts
//
// Converts Gemini JSON session messages to the shared ParsedMessage format.
// Much simpler than Claude's parser because Gemini embeds tool calls and
// thinking directly in each message (no cross-entry tracking needed).
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
import type { ContentBlock } from '../claude/message-utils.js';
import {
extractUserText, extractGeminiText, toolCallsToContentBlocks,
type GeminiToolCall,
} from './message-utils.js';
/** Parsed message for frontend rendering (shared format across adapters) */
export interface ParsedMessage {
id: string;
role: 'user' | 'assistant' | 'plan';
content: ContentBlock[]; // Always array — never string (consistent with Claude/Codex)
adapter?: string;
}
/** Result of parse() */
export interface ParseResult {
messages: ParsedMessage[];
errors: string[]; // Error messages to emit as session-error events
}
/** Token/model info extracted from gemini messages */
export interface StatusInfo {
model: string | null;
tokens: Record<string, number> | null;
}
/** Thinking entry from Gemini message */
export interface ThoughtEntry {
subject: string;
description: string;
timestamp: string;
}
export class GeminiTranscriptParser {
private _msgIndex: number = 0;
/**
* Parse Gemini session messages into frontend-ready format.
* Called incrementally — _msgIndex is NOT reset between calls.
*/
parse(messages: GeminiSessionMessage[]): ParseResult {
const parsed: ParsedMessage[] = [];
const errors: string[] = [];
for (const msg of messages) {
switch (msg.type) {
case 'user': {
const text = extractUserText(msg.content);
if (!text.trim()) continue;
const userContent: ContentBlock[] = Array.isArray(msg.content)
? (msg.content as ContentBlock[])
: [{ type: 'text', text }];
parsed.push({
id: `msg-${this._msgIndex++}`,
role: 'user',
content: userContent,
adapter: 'gemini',
});
break;
}
case 'gemini': {
const textContent = extractGeminiText(msg.content);
const blocks: ContentBlock[] = [];
// Add text block if present
if (textContent) {
blocks.push({ type: 'text', text: textContent });
}
// Convert embedded toolCalls to ContentBlocks
if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
blocks.push(...toolCallsToContentBlocks(msg.toolCalls as GeminiToolCall[]));
}
if (blocks.length === 0) continue;
parsed.push({
id: `msg-${this._msgIndex++}`,
role: 'assistant',
content: blocks,
adapter: 'gemini',
});
break;
}
case 'error': {
const errorText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
errors.push(errorText);
break;
}
case 'info':
// Skip internal CLI info messages
break;
}
}
return { messages: parsed, errors };
}
/** Extract thinking entries from a gemini message */
static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] {
if (msg.type !== 'gemini' || !msg.thoughts || !Array.isArray(msg.thoughts)) return [];
return msg.thoughts as ThoughtEntry[];
}
/** Extract status info (model, tokens) from a gemini message */
static extractStatus(msg: GeminiSessionMessage): StatusInfo | null {
if (msg.type !== 'gemini') return null;
if (!msg.model && !msg.tokens) return null;
return {
model: (msg.model as string) || null,
tokens: (msg.tokens as Record<string, number>) || null,
};
}
}
- Step 2: Commit
git add server/adapters/gemini/transcript-parser.ts && git commit -m "feat(gemini): add transcript parser (JSON -> ParsedMessage)"
Task 5: Json Store — Session Discovery
Files:
-
Create:
server/adapters/gemini/json-store.ts -
Step 1: Create
server/adapters/gemini/json-store.ts
Reference server/adapters/claude/jsonl-store.ts for the SessionInfo type from server/types/adapter.ts. Gemini uses ~/.gemini/projects.json for project mapping and ~/.gemini/tmp/<name>/chats/ for session files.
// server/adapters/gemini/json-store.ts
//
// Session discovery for Gemini CLI sessions.
// Sessions are stored as single JSON files in ~/.gemini/tmp/<project>/chats/
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
import type { SessionInfo } from '../../types/adapter.js';
import { extractUserText } from './message-utils.js';
const GEMINI_DIR = join(homedir(), '.gemini');
const TMP_DIR = join(GEMINI_DIR, 'tmp');
const PROJECTS_JSON = join(GEMINI_DIR, 'projects.json');
/** Read ~/.gemini/projects.json -> { "/abs/path": "project-name" } */
function readProjectsMapping(): Record<string, string> {
try {
const data = JSON.parse(readFileSync(PROJECTS_JSON, 'utf-8'));
return data.projects || {};
} catch { return {}; }
}
/** Get project name for a given directory path */
export function getProjectName(dir: string): string | null {
const mapping = readProjectsMapping();
return mapping[dir] || null;
}
/** Get project root path from .project_root file */
function getProjectRoot(projectName: string): string | null {
try {
return readFileSync(join(TMP_DIR, projectName, '.project_root'), 'utf-8').trim();
} catch { return null; }
}
/** List all project directories in ~/.gemini/tmp/ */
function listProjectDirs(): string[] {
try {
return readdirSync(TMP_DIR).filter(name => {
try {
return statSync(join(TMP_DIR, name)).isDirectory() && name !== 'bin';
} catch { return false; }
});
} catch { return []; }
}
/** List session files for a specific project */
function listSessionFiles(projectName: string): string[] {
const chatsDir = join(TMP_DIR, projectName, 'chats');
try {
return readdirSync(chatsDir)
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
.map(f => join(chatsDir, f));
} catch { return []; }
}
/** Read a session file and extract metadata */
function readSessionMeta(filePath: string): SessionInfo | null {
try {
const content = readFileSync(filePath, 'utf-8');
const session = JSON.parse(content);
const messages = session.messages || [];
// Find first user message text
const firstUser = messages.find((m: any) => m.type === 'user');
const firstPrompt = firstUser ? extractUserText(firstUser.content) : null;
// Find latest model from last gemini message
let model: string | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].type === 'gemini' && messages[i].model) {
model = messages[i].model;
break;
}
}
const stat = statSync(filePath);
return {
sessionId: session.sessionId,
cwd: '', // Will be set by caller from project root
lastModified: session.lastUpdated || stat.mtime.toISOString(),
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
model,
// NOTE: SessionInfo type does NOT have an 'adapter' field.
// Adapter identification happens at the API layer, not in the store.
};
} catch { return null; }
}
/** Get sessions for a specific directory (or all projects if dir is omitted) */
export function getSessions(dir?: string, limit: number = 50): SessionInfo[] {
const sessions: SessionInfo[] = [];
if (dir) {
const projectName = getProjectName(dir);
if (!projectName) return [];
const files = listSessionFiles(projectName);
for (const file of files) {
const meta = readSessionMeta(file);
if (meta) { meta.cwd = dir; sessions.push(meta); }
}
} else {
// All projects
for (const projectName of listProjectDirs()) {
const projectRoot = getProjectRoot(projectName);
const files = listSessionFiles(projectName);
for (const file of files) {
const meta = readSessionMeta(file);
if (meta) { meta.cwd = projectRoot || ''; sessions.push(meta); }
}
}
}
sessions.sort((a, b) => {
const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0;
const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0;
return tb - ta;
});
return sessions.slice(0, limit);
}
/** Find a session file by sessionId across all projects */
export function findSessionFile(sessionId: string): string | null {
for (const projectName of listProjectDirs()) {
const files = listSessionFiles(projectName);
for (const file of files) {
try {
const content = readFileSync(file, 'utf-8');
const session = JSON.parse(content);
if (session.sessionId === sessionId) return file;
} catch { continue; }
}
}
return null;
}
/** Get all messages from a session file */
export function getSessionMessages(sessionId: string, dir?: string): { messages: unknown[]; lastModified: string | null } {
let filePath: string | null = null;
if (dir) {
const projectName = getProjectName(dir);
if (projectName) {
const files = listSessionFiles(projectName);
for (const file of files) {
try {
const content = readFileSync(file, 'utf-8');
const session = JSON.parse(content);
if (session.sessionId === sessionId) { filePath = file; break; }
} catch { continue; }
}
}
}
if (!filePath) filePath = findSessionFile(sessionId);
if (!filePath) return { messages: [], lastModified: null };
try {
const content = readFileSync(filePath, 'utf-8');
const session = JSON.parse(content);
return {
messages: session.messages || [],
lastModified: session.lastUpdated || null,
};
} catch { return { messages: [], lastModified: null }; }
}
Important notes for implementer:
-
getSessionMessages()maps toIAdapter.getMessages()— the GeminiAdapter (Task 9) callsgetSessionMessagesinternally -
Add
listDirectory(path?)export — reuse the pattern from Claude'sjsonl-store.ts(reads a directory and returnsDirectoryEntry[]). Gemini projects are rooted in whatever.project_rootsays. -
All
readFileSynccalls are acceptable for <34KB files. ForfindSessionFile()which scans all projects, consider capping at 100 files. -
Step 2: Commit
git add server/adapters/gemini/json-store.ts && git commit -m "feat(gemini): add json-store for session discovery"
Task 6: Hook Config & Bridge Script
Files:
-
Create:
server/adapters/gemini/hook-config.ts -
Create:
server/adapters/gemini/bridge.sh -
Step 1: Create
server/adapters/gemini/bridge.sh
Copy the bridge script from the spec verbatim. Make it executable.
cat > server/adapters/gemini/bridge.sh << 'BRIDGE'
#!/bin/bash
# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server.
#
# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write
# a response BEFORE backgrounding the curl POST, or Gemini will hang.
# Exit code 0 = allow (continue), exit code 2 = block.
ENDPOINT="$1"
PORT="${CODETAP_PORT:-3456}"
PROTOCOL="${CODETAP_PROTOCOL:-http}"
CURL_K=""
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
# Read stdin (Gemini hook JSON payload)
input=$(cat)
# Respond to Gemini immediately
printf '{}'
# Port check: skip curl if server isn't listening
(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0
# Forward payload to code-tap server asynchronously
printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \
-X POST -H 'Content-Type:application/json' -d @- \
"${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null &
BRIDGE
chmod +x server/adapters/gemini/bridge.sh
- Step 2: Create
server/adapters/gemini/hook-config.ts
Follow the pattern from server/adapters/claude/hook-config.ts — read/write ~/.gemini/settings.json, use portTag for ownership identification, wrap (don't replace) existing hooks.
The hook-config must:
- Set
CODETAP_PORTandCODETAP_PROTOCOLenv vars in the command string - Use absolute path to bridge.sh
- Install hooks for:
BeforeTool,AfterTool,BeforeAgent,AfterAgent,SessionStart,SessionEnd
// server/adapters/gemini/hook-config.ts
//
// Pure filesystem operations for Gemini CLI hook management.
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
//
// Key differences from Claude/Codex:
// - Hooks live in ~/.gemini/settings.json under the "hooks" key
// - Uses bridge.sh (stdin JSON -> curl POST) instead of direct curl
// - No statusLine wrapping (Gemini has no statusLine hook)
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
interface HookAction {
type?: string;
command?: string;
timeout?: number;
}
interface HookEntry {
matcher?: string;
hooks: HookAction[];
}
interface GeminiSettings {
hooks?: Record<string, HookEntry[]>;
[key: string]: unknown;
}
export class GeminiHookConfig {
port: number | string;
useHttps: boolean;
constructor(port?: number | string, useHttps?: boolean) {
this.port = port || process.env.PORT || 3456;
if (useHttps !== undefined) {
this.useHttps = useHttps;
} else {
const codetapDir = join(homedir(), '.codetap');
this.useHttps = existsSync(join(codetapDir, 'cert.pem')) && existsSync(join(codetapDir, 'key.pem'));
}
}
install(): void {
const settingsDir = join(homedir(), '.gemini');
const settingsPath = join(settingsDir, 'settings.json');
const { portTag } = this._hookIdentifiers();
const desiredHooks = this._buildDesiredHooks();
try {
mkdirSync(settingsDir, { recursive: true });
let existing: GeminiSettings = {};
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {}
if (!existing.hooks) existing.hooks = {};
for (const [event, configs] of Object.entries(desiredHooks)) {
const existingEntries = existing.hooks[event] || [];
const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag));
existing.hooks[event] = [...filtered, ...configs];
}
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`);
} catch (err) {
console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`);
}
}
uninstall(): void {
const { portTag } = this._hookIdentifiers();
const settingsPath = join(homedir(), '.gemini', 'settings.json');
try {
const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings;
if (existing.hooks) {
const hookKeys = Object.keys(this._buildDesiredHooks());
for (const key of hookKeys) {
const entries = existing.hooks[key];
if (!Array.isArray(entries)) continue;
const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag));
if (filtered.length === 0) {
delete existing.hooks[key];
} else {
existing.hooks[key] = filtered;
}
}
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
}
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks:gemini] Removed CodeTap hooks from ${settingsPath}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`);
}
}
private _hookIdentifiers(): { portTag: string } {
return { portTag: `CODETAP_PORT=${this.port}` };
}
private _isOurHookEntry(entry: HookEntry, portTag: string): boolean {
return (entry.hooks || []).some(h => h.command && h.command.includes(portTag));
}
private _buildDesiredHooks(): Record<string, HookEntry[]> {
const bridgePath = resolve(__dirname, 'bridge.sh');
const protocol = this.useHttps ? 'https' : 'http';
const envPrefix = `CODETAP_PORT=${this.port} CODETAP_PROTOCOL=${protocol}`;
const cmd = (endpoint: string): string => `${envPrefix} ${bridgePath} ${endpoint}`;
return {
SessionStart: [{ hooks: [{ type: 'command', command: cmd('session-start'), timeout: 3 }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: cmd('session-end'), timeout: 3 }] }],
BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('before-tool'), timeout: 3 }] }],
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('after-tool'), timeout: 3 }] }],
BeforeAgent: [{ hooks: [{ type: 'command', command: cmd('before-agent'), timeout: 3 }] }],
AfterAgent: [{ hooks: [{ type: 'command', command: cmd('after-agent'), timeout: 3 }] }],
};
}
}
- Step 3: Commit
git add server/adapters/gemini/hook-config.ts server/adapters/gemini/bridge.sh && git commit -m "feat(gemini): add hook-config and bridge script"
Task 7: Pane Monitor
Files:
-
Create:
server/adapters/gemini/pane-monitor.ts -
Step 1: Create
server/adapters/gemini/pane-monitor.ts
Follow the pattern from server/adapters/codex/pane-monitor.ts — takes sessionId, windowId, tmux manager, and EventEmitter. Gemini TUI patterns will need empirical refinement, so start with conservative placeholder patterns similar to Codex.
The key difference from Claude/Codex: Gemini already provides thinking in the JSON, so the pane monitor's thinking detection is supplementary (for real-time streaming before JSON is written).
// server/adapters/gemini/pane-monitor.ts
//
// Polls tmux pane for real-time streaming output from Gemini CLI.
// Detects: streaming text, thinking indicators.
// Note: Gemini provides thinking in JSON (thoughts[]) — pane monitor
// provides real-time streaming BEFORE JSON is written to disk.
import { EventEmitter } from 'events';
interface TmuxCapture {
capturePane(windowId: string, lines?: number): Promise<string>;
}
export interface ThinkingInfo {
text: string;
detail: string | null;
}
export class GeminiPaneMonitor {
private sessionId: string;
private windowId: string;
private tmux: TmuxCapture;
private emitter: EventEmitter;
private interval: ReturnType<typeof setInterval> | null = null;
private _lastContent: string = '';
private _lastResponseText: string = '';
constructor(
sessionId: string,
windowId: string,
tmuxManager: TmuxCapture,
emitter: EventEmitter,
) {
this.sessionId = sessionId;
this.windowId = windowId;
this.tmux = tmuxManager;
this.emitter = emitter;
}
start(): void {
if (this.interval) return;
this.interval = setInterval(() => this._poll(), 500);
}
stop(): void {
if (this.interval) { clearInterval(this.interval); this.interval = null; }
}
async pollNow(): Promise<void> { await this._poll(); }
private async _poll(): Promise<void> {
try {
const content = await this.tmux.capturePane(this.windowId);
if (content === this._lastContent) return;
this._lastContent = content;
// 1. Check for thinking indicator
const thinking = detectThinking(content);
if (thinking) {
this.emitter.emit('thinking', this.sessionId, thinking);
return;
}
// 2. Extract streaming response text
const text = extractResponseText(content);
if (text && text !== this._lastResponseText) {
this._lastResponseText = text;
this.emitter.emit('streaming-text', this.sessionId, text);
}
} catch {
// Silently ignore — tmux window may have been killed
}
}
}
// --- Detection functions (exported for testing) ---
/** Detect Gemini CLI thinking/processing indicators */
export function detectThinking(content: string): ThinkingInfo | null {
const lines = content.split('\n');
const tail = lines.slice(-15);
for (const line of tail) {
if (/completed|finished|done|exited/i.test(line)) continue;
// Gemini uses braille spinners and "Thinking..." text
const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/);
if (brailleMatch) return { text: brailleMatch[2]!, detail: null };
const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing|Searching)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i);
if (thinkingMatch) return { text: `${thinkingMatch[1]}...`, detail: thinkingMatch[3] || null };
}
return null;
}
/** Extract streaming response text from Gemini pane content */
export function extractResponseText(content: string): string {
const lines = content.split('\n');
// Find last user prompt (Gemini uses > or ❯)
let lastUserPrompt = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/^\s*[>❯]\s+\S/.test(lines[i]!)) { lastUserPrompt = i; break; }
}
if (lastUserPrompt === -1) return '';
let responseStart = lastUserPrompt + 1;
while (responseStart < lines.length && lines[responseStart]!.trim() === '') responseStart++;
if (responseStart >= lines.length) return '';
const responseLines: string[] = [];
for (let i = responseStart; i < lines.length; i++) {
const line = lines[i]!;
if (/^[─━═\-]{5,}/.test(line.trim()) ||
/^\s*[>❯]\s+\S/.test(line) ||
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line)) break;
responseLines.push(line);
}
return responseLines.join('\n').trim();
}
- Step 2: Commit
git add server/adapters/gemini/pane-monitor.ts && git commit -m "feat(gemini): add pane monitor for streaming text detection"
Task 8: GeminiTmuxAdapter — Session Lifecycle
Files:
-
Create:
server/adapters/gemini/gemini-tmux-adapter.ts -
Step 1: Create
server/adapters/gemini/gemini-tmux-adapter.ts
This is the largest file. Model it closely on server/adapters/codex/codex-tmux-adapter.ts since both share the same pattern: session ID discovered from hook, _pendingHookBodies for race conditions, JSONL/JSON watcher started on SessionStart hook.
Key differences from Codex:
- Uses
JsonWatcherinstead ofJsonlWatcher - Uses
GeminiTranscriptParserinstead ofCodexTranscriptParser - More hook events (BeforeTool, AfterTool, BeforeAgent, AfterAgent)
- Permission toggle via Ctrl+Y (binary YOLO toggle)
- Model switch via
/model <name>slash command
This file is large (~400-500 lines). The implementer should:
- Read
server/adapters/codex/codex-tmux-adapter.tsin full - Copy its structure, adapting for Gemini's specifics
- Key methods:
startSession,resumeSession,sendMessage,interrupt,switchPermissionMode,switchModel,handleSessionStart,handleBeforeTool,handleAfterTool,handleBeforeAgent,handleAfterAgent,handleSessionEnd
The complete implementation is too large for inline code in this plan. The implementer should use the Codex adapter as a template and the spec's Section 7 (Session Lifecycle) for Gemini-specific behavior.
- Step 2: Verify — file compiles
npx tsc --noEmit server/adapters/gemini/gemini-tmux-adapter.ts 2>&1 | head -20
- Step 3: Commit
git add server/adapters/gemini/gemini-tmux-adapter.ts && git commit -m "feat(gemini): add tmux adapter for session lifecycle"
Task 9: GeminiAdapter — Main Entry Point
Files:
-
Create:
server/adapters/gemini/index.ts -
Step 1: Create
server/adapters/gemini/index.ts
Model on server/adapters/codex/index.ts. Wires the GeminiTmuxAdapter, GeminiHookConfig, and json-store together. Registers HTTP hook routes via setup(app).
Key aspects:
static id = 'gemini',static displayName = 'Gemini CLI',static command = 'gemini'setup(app): register routes for/api/hooks/gemini/session-start,before-tool,after-tool,before-agent,after-agent,session-end- Delegate all IAdapter methods to GeminiTmuxAdapter
getModels(),getPermissionModes(),getCapabilities()as defined in spec Section 6installHooks()/uninstallHooks()delegate to GeminiHookConfig
Again, too large for inline code. The implementer should use server/adapters/codex/index.ts as a template.
- Step 2: Verify — server starts with gemini adapter loaded
npx tsx server/index.ts 2>&1 | head -20
Expected: Should see [init] Loaded adapter: gemini (or no error if gemini CLI not installed — registry skips it).
- Step 3: Commit
git add server/adapters/gemini/index.ts && git commit -m "feat(gemini): add GeminiAdapter main entry point"
Task 10: Registration & CLI Integration
Files:
-
Modify:
server/adapters/init.ts -
Modify:
server/adapters/registry.ts -
Modify:
bin/hooks-cli.mjs -
Modify:
bin/codetap -
Step 1: Update
server/adapters/init.ts— add gemini loader
Add to LOADERS:
gemini: () => import('./gemini/index.js').then(m => m.GeminiAdapter),
- Step 2: Update
server/adapters/registry.ts— add gemini to defaults
Change line 29 from:
: ['claude', 'codex'];
To:
: ['claude', 'codex', 'gemini'];
- Step 3: Update
bin/hooks-cli.mjs— add GeminiHookConfig
Add import:
import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js';
Add instance:
const gemini = new GeminiHookConfig();
Add to install/uninstall:
if (cmd === 'install') {
claude.install();
codex.install();
gemini.install();
} else {
claude.uninstall();
codex.uninstall();
gemini.uninstall();
}
- Step 4: Update
bin/codetap— add gemini support
Five changes:
set_adapter(): addgemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;;- Adapter detection: add
*gemini*) SESS_ADAPTER="gemini" ;; - ANSI label: add
gemini) LABEL="\033[34m[Gemini]\033[0m" ;; --adaptervalidation: addgemini) set_adapter gemini ;;- Help text (line ~45): update
--adapter <name>description to(claude, codex, gemini)
- Step 5: Verify —
codetap --helpshows gemini, hooks install works
./bin/codetap --help
node bin/hooks-cli.mjs install 2>&1 | grep gemini
node bin/hooks-cli.mjs uninstall 2>&1 | grep gemini
- Step 6: Commit
git add server/adapters/init.ts server/adapters/registry.ts bin/hooks-cli.mjs bin/codetap && git commit -m "feat(gemini): wire up registration, CLI, and hook management"
Task 11: Frontend — Adapter Brand & Icon
Files:
-
Modify:
src/lib/adapter-brands.ts -
Modify:
src/components/AdapterIcon.tsx -
Step 1: Update
src/lib/adapter-brands.ts
Extend the AdapterBrand type to include 'gemini' in iconType:
iconType: 'claude' | 'codex' | 'gemini';
Add gemini brand to ADAPTER_BRANDS:
gemini: {
id: 'gemini',
displayName: 'Gemini',
provider: 'Google',
color: '#4285f4',
colorBg: '#4285f422',
gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)',
glow: 'rgba(66,133,244,0.3)',
iconType: 'gemini',
},
- Step 2: Update
src/components/AdapterIcon.tsx
- Add
GeminiIconcomponent — find the official Google Gemini star SVG from thesvg.org - Refactor the icon selection from if/else to a map:
const ICON_MAP: Record<string, React.FC<{ size: number }>> = {
claude: ClaudeIcon,
codex: CodexIcon,
gemini: GeminiIcon,
};
// In AdapterIcon:
const Icon = ICON_MAP[brand.iconType] || ClaudeIcon;
return <span className={className} style={{ color: brand.color, display: 'inline-flex' }}><Icon size={size} /></span>;
- Step 3: Verify —
npm run devbuilds, open browser, check adapter selector shows Gemini
npm run dev
Open http://localhost:5173, check settings → adapter list shows Gemini with blue icon.
- Step 4: Commit
git add src/lib/adapter-brands.ts src/components/AdapterIcon.tsx && git commit -m "feat(gemini): add Gemini brand and icon to frontend"
Task 12: End-to-End Verification
- Step 1: Start code-tap server
CLAUDE_UI_PASSWORD=test npm run dev
- Step 2: Verify adapter registration
curl -s http://localhost:3456/health | python3 -m json.tool
curl -sk -X POST http://localhost:3456/api/auth/login -H 'Content-Type: application/json' -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])'
# Use token for:
curl -s http://localhost:3456/api/adapters -H 'Authorization: Bearer <token>' | python3 -m json.tool
Expected: Response includes { id: "gemini", displayName: "Gemini CLI", available: true/false, capabilities: {...} }
- Step 3: Verify hooks install/uninstall
node bin/hooks-cli.mjs install
cat ~/.gemini/settings.json | python3 -m json.tool
# Should show hooks.BeforeTool, hooks.AfterTool, etc. with bridge.sh paths
node bin/hooks-cli.mjs uninstall
cat ~/.gemini/settings.json | python3 -m json.tool
# Hooks should be removed
- Step 4: Verify session listing
curl -s http://localhost:3456/api/sessions?adapter=gemini -H 'Authorization: Bearer <token>' | python3 -m json.tool
Expected: Returns existing Gemini sessions from ~/.gemini/tmp/*/chats/ (if any exist).
- Step 5: Manual test — full flow (if Gemini CLI is working)
- Open phone browser → code-tap
- Select Gemini adapter
- Start new session
- Send a prompt
- Verify: streaming text appears, thinking shows, tool calls render
- Resume session works
- Step 6: Final commit (if any fixes needed)
git add -A && git commit -m "fix(gemini): end-to-end verification fixes"