feat: ClawTap v0.1.0 — initial release

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
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Reads JSON from stdin (Gemini hook protocol), POSTs to claw-tap server.
# IMPORTANT: Gemini hooks expect a JSON response on stdout.
# Must write response BEFORE backgrounding curl, or Gemini hangs.
#
# Usage: bridge.sh <endpoint> <port> <protocol>
# e.g.: bridge.sh session-start 3456 https
ENDPOINT="$1"
PORT="${2:-3456}"
PROTOCOL="${3:-http}"
CURL_K=""
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
input=$(cat)
printf '{}'
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 &
@@ -0,0 +1,848 @@
// server/adapters/gemini/gemini-tmux-adapter.ts
//
// Session lifecycle management for Gemini CLI sessions running in tmux.
//
// Key difference from Codex's CodexTmuxAdapter:
// - Gemini has 6 hooks: SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent
// - Tool lifecycle comes from hooks (BeforeTool/AfterTool), not just JSON watching
// - Uses JsonWatcher (full JSON reparse) instead of JsonlWatcher (append-only)
// - Permission mode uses Ctrl+Y for default <-> yolo toggle
import { EventEmitter } from 'events';
import { tmuxManager } from '../shared/tmux-manager.js';
import { GeminiPaneMonitor } from './pane-monitor.js';
import { JsonWatcher } from '../../stores/json-watcher.js';
import { GeminiTranscriptParser } from './transcript-parser.js';
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
import type { PermissionBehavior, QueryOptions } from '../../types/messages.js';
import type { ReconnectState } from '../../types/adapter.js';
import type { ActiveSessionInfo } from '../interface.js';
import { isLargeContent } from '../interface.js';
import { PermissionManager } from '../../permission-manager.js';
import { readFile } from 'fs/promises';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Hook body payload from the Gemini CLI */
export interface GeminiHookBody {
session_id?: string;
cwd?: string;
model?: string;
hook_event_name?: string;
transcript_path?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_response?: unknown;
[key: string]: unknown;
}
/** Internal session state for a managed tmux session */
export interface GeminiSessionState {
windowId: string;
monitor: GeminiPaneMonitor | null;
watcher: JsonWatcher | null;
parser: GeminiTranscriptParser | null;
cwd: string;
cliSessionId: string;
transcriptPath: string | null;
permissionMode: string;
lastActivity: number;
firstPrompt: string | null;
isProcessing: boolean;
_promptSenderClientId: string | null;
_watcherPending: boolean;
_matchRetryTimer: ReturnType<typeof setTimeout> | null;
}
/** Hook body with timestamp for age-based cleanup */
type PendingHookBody = GeminiHookBody & { _storedAt: number };
/** Resolved session context from _resolveAndTouch */
interface ResolvedContext {
sessionId: string;
session: GeminiSessionState | undefined;
}
// ---------------------------------------------------------------------------
// GeminiTmuxAdapter
// ---------------------------------------------------------------------------
/**
* GeminiTmuxAdapter — manages Gemini CLI sessions via tmux.
*
* Three channels provide events to the SessionManager:
* 1. HTTP Hooks (lifecycle): SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent
* 2. JSON Watcher (messages): new-messages, thinking, status-update
* 3. PaneMonitor (ephemeral): streaming-text, thinking
*
* Events emitted:
* streaming-text(sessionId, text)
* thinking(sessionId, { text, detail })
* tool-start(sessionId, { toolId, toolName, input })
* tool-done(sessionId, { toolId, toolName, result })
* new-messages(sessionId, messages[])
* session-idle(sessionId)
* processing-started(sessionId)
* status-update(sessionId, { model, tokens })
* session-error(sessionId, { errorType, errorDetails })
* session-ended(sessionId)
* session-rekeyed(oldKey, newKey)
*/
export class GeminiTmuxAdapter extends EventEmitter {
// sessionId -> session state
sessions: Map<string, GeminiSessionState>;
private _permissions: PermissionManager;
private _clientChecker: ((sessionId: string) => boolean) | null;
private _cleanupInterval: ReturnType<typeof setInterval> | null;
private _pendingHookBodies: Map<string, PendingHookBody> = new Map();
// Track tool IDs from BeforeTool → AfterTool so events correlate
private _activeToolId: string | null = null;
constructor() {
super();
this.sessions = new Map();
this._permissions = new PermissionManager();
this._clientChecker = null;
this._cleanupInterval = null;
this._startSessionCleanup();
}
/** Set a function that checks if WS clients are connected for a session */
setClientChecker(fn: (sessionId: string) => boolean): void {
this._clientChecker = fn;
}
// === Session Lifecycle ===
async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
const mode = options.permissionMode || 'default';
const parts = ['gemini', '--approval-mode', this._toCliApprovalMode(mode)];
if (options.model) parts.push('-m', options.model);
const tempName = `gemini-${Date.now()}`;
const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' '));
// Register session BEFORE _waitForReady — SessionStart hook fires during
// CLI startup and needs to find this session in the Map for matching.
this.sessions.set(tempName, this._createSession(windowId, cwd, '', mode));
await this._waitForReady(windowId);
// After _waitForReady, SessionStart hook may have fired and rekeyed
// the session from tempName to the real CLI UUID. Return the current key.
let finalId = tempName;
if (!this.sessions.has(tempName)) {
const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0];
if (rekeyed) {
finalId = rekeyed;
} else {
console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`);
}
}
this._startMonitor(finalId, windowId);
return { sessionId: finalId };
}
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
const session = this.sessions.get(sessionId);
const geminiUuid = session?.cliSessionId || sessionId;
const mode = options.permissionMode || session?.permissionMode || 'default';
// Check if tmux window still alive
if (session) {
const windows = await tmuxManager.listWindows();
if (windows.some(w => w.id === session.windowId)) {
if (!session.monitor) this._startMonitor(sessionId, session.windowId);
session.permissionMode = mode;
session.lastActivity = Date.now();
return { sessionId };
}
// Window gone — teardown old
this._teardownSession(session);
}
const parts = ['gemini', '--resume', geminiUuid, '--approval-mode', this._toCliApprovalMode(mode)];
if (options.model) parts.push('-m', options.model);
const newSessionId = geminiUuid;
const windowId = await tmuxManager.createWindow(geminiUuid, cwd, parts.join(' '));
// Register before _waitForReady — same pattern as startSession
if (session) {
if (sessionId !== newSessionId) this.sessions.delete(sessionId);
session.windowId = windowId;
session.lastActivity = Date.now();
session.permissionMode = mode;
session._watcherPending = true;
session.transcriptPath = null;
session.watcher = null;
session.parser = null;
this.sessions.set(newSessionId, session);
} else {
this.sessions.set(newSessionId, this._createSession(windowId, cwd, geminiUuid, mode));
}
await this._waitForReady(windowId);
this._startMonitor(newSessionId, windowId);
return { sessionId: newSessionId };
}
/**
* Toggle permission mode via Ctrl+Y.
* Only supports binary toggle: default <-> yolo at runtime.
* auto_edit and plan are only settable at session launch.
*/
async switchPermissionMode(sessionId: string, targetMode: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) return false;
// Ctrl+Y toggles default <-> yolo
await tmuxManager.sendControl(session.windowId, 'C-y');
session.permissionMode = targetMode;
return true;
}
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
session.isProcessing = true;
// Restart pane monitor if it was stopped
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
if (isLargeContent(text)) {
const singleLine = text.replace(/\n/g, '\\n');
const markerMatch = singleLine.match(/^\[CLAWTAP_REF:[^\]]+\]/);
if (markerMatch) {
const marker = markerMatch[0];
const rest = singleLine.substring(marker.length);
await tmuxManager.sendKeys(session.windowId, marker, false);
await new Promise<void>(r => setTimeout(r, 200));
if (rest) {
await tmuxManager.pasteBuffer(session.windowId, rest, false);
}
} else {
await tmuxManager.pasteBuffer(session.windowId, singleLine, false);
}
await new Promise<void>(r => setTimeout(r, 300));
await tmuxManager.sendControl(session.windowId, 'Enter');
} else {
await tmuxManager.sendKeys(session.windowId, text, false);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendControl(session.windowId, 'Enter');
}
// If there are pending hook bodies waiting for marker matching, try now
if (this._pendingHookBodies.size > 0 && session._watcherPending) {
this._tryMatchPending(sessionId);
}
}
async switchModel(sessionId: string, model: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
await tmuxManager.sendKeys(session.windowId, `/model ${model}`, false);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendControl(session.windowId, 'Enter');
}
async interrupt(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
await tmuxManager.sendControl(session.windowId, 'C-c');
session.isProcessing = false;
if (session.monitor) {
session.monitor.stop();
session.monitor = null;
}
}
async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this._teardownSession(session);
await tmuxManager.killWindow(session.windowId);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
// === Hook Handlers ===
/**
* Handle the SessionStart hook from Gemini CLI.
*
* This is the moment we learn the transcript_path and can start the JSON watcher.
* It may also be the first time we see the Gemini session UUID for sessions started via startSession().
*/
handleSessionStart(body: GeminiHookBody): void {
const geminiUuid = body.session_id;
if (!geminiUuid) return;
// 1. Already managed (resume, or session with known UUID)
if (this.sessions.has(geminiUuid)) {
this._applySessionStartBody(geminiUuid, body);
return;
}
// 2. Find pending sessions (_watcherPending === true)
const pending = [...this.sessions.entries()].filter(([, s]) => s._watcherPending);
if (pending.length === 0) return; // Not our session
// 3. Exactly 1 pending -> direct match (no marker needed)
if (pending.length === 1) {
const [tempKey] = pending[0];
console.log(`[gemini-tmux] Direct match: ${tempKey} -> ${geminiUuid}`);
this._rekeyAndRename(tempKey, geminiUuid);
this._applySessionStartBody(geminiUuid, body);
return;
}
// 4. Multiple pending -> store, wait for sendMessage to disambiguate via marker
this._pendingHookBodies.set(geminiUuid, { ...body, _storedAt: Date.now() });
}
/**
* Handle the BeforeTool hook from Gemini CLI.
* Emits tool-start for the tool about to run.
*/
handleBeforeTool(body: GeminiHookBody): void {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId } = ctx;
const toolId = body.tool_use_id || `${body.tool_name}-${Date.now()}`;
this._activeToolId = toolId;
this.emit('tool-start', sessionId, {
toolId,
toolName: body.tool_name || 'unknown',
input: body.tool_input || {},
});
}
/**
* Handle the AfterTool hook from Gemini CLI.
* Emits tool-done for the tool that just finished.
*/
handleAfterTool(body: GeminiHookBody): void {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId } = ctx;
// Use the toolId from BeforeTool if available, ensuring start/done events correlate
const toolId = this._activeToolId || body.tool_use_id || `${body.tool_name}-${Date.now()}`;
this._activeToolId = null;
let resultStr = '';
if (body.tool_response !== undefined && body.tool_response !== null) {
resultStr = typeof body.tool_response === 'string'
? body.tool_response
: JSON.stringify(body.tool_response);
}
this.emit('tool-done', sessionId, {
toolId,
toolName: body.tool_name || 'unknown',
result: resultStr,
});
}
/**
* Handle the BeforeAgent hook from Gemini CLI.
* Signals that the agent is starting to process.
*/
handleBeforeAgent(body: GeminiHookBody): void {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId, session } = ctx;
if (session) {
session.isProcessing = true;
if (!session.monitor && session.windowId) {
this._startMonitor(sessionId, session.windowId);
}
}
this.emit('processing-started', sessionId);
}
/**
* Handle the AfterAgent hook from Gemini CLI.
* Signals that the agent has finished processing (turn complete).
*/
handleAfterAgent(body: GeminiHookBody): void {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId, session } = ctx;
if (session) {
session.isProcessing = false;
if (session.monitor) {
session.monitor.stop();
session.monitor = null;
}
// Flush JSON watcher to get final entries
if (session.watcher) {
session.watcher.pollNow();
}
}
this.emit('session-idle', sessionId);
this._permissions.dismissAll(sessionId);
}
/**
* Handle the SessionEnd hook from Gemini CLI.
* Cleans up the session.
*/
handleSessionEnd(body: GeminiHookBody): void {
const sessionId = body.session_id;
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
this._teardownSession(session);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
// === JSON Watcher ===
/**
* Process new JSON messages through the transcript parser and emit events.
*/
private _processWatcherMessages(sessionId: string, rawMessages: GeminiSessionMessage[]): void {
const session = this.sessions.get(sessionId);
if (!session?.parser) return;
const result = session.parser.parse(rawMessages);
// Emit errors as session-error events
for (const errText of result.errors) {
this.emit('session-error', sessionId, {
errorType: 'gemini_error',
errorDetails: errText,
});
}
// Single pass: extract thoughts + status from gemini/info messages
for (const msg of rawMessages) {
if (msg.type === 'gemini') {
const thoughts = GeminiTranscriptParser.extractThoughts(msg);
for (const thought of thoughts) {
this.emit('thinking', sessionId, {
text: thought.subject || 'Thinking...',
detail: thought.description || null,
});
}
const status = GeminiTranscriptParser.extractStatus(msg);
if (status) this.emit('status-update', sessionId, status);
} else if (msg.type === 'info') {
const status = GeminiTranscriptParser.extractStatus(msg);
if (status) this.emit('status-update', sessionId, status);
}
}
// Emit messages
if (result.messages.length > 0) {
// Capture first user prompt for active sessions list
if (!session.firstPrompt) {
const userMsg = result.messages.find(m => m.role === 'user');
if (userMsg) {
const text = userMsg.content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map(c => c.text)
.join('\n');
if (text) {
const stripped = text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '');
session.firstPrompt = stripped.substring(0, 200);
}
}
}
// Tag user messages with sender's client ID so only the sender skips (dedup)
for (const msg of result.messages) {
if (msg.role === 'user' && session._promptSenderClientId) {
msg.senderClientId = session._promptSenderClientId;
session._promptSenderClientId = null;
}
}
this.emit('new-messages', sessionId, result.messages);
}
}
// === Query Methods ===
getSession(sessionId: string): GeminiSessionState | undefined {
return this.sessions.get(sessionId);
}
getActiveSessions(): ActiveSessionInfo[] {
const result: ActiveSessionInfo[] = [];
for (const [sessionId, session] of this.sessions) {
result.push({
sessionId,
cwd: session.cwd,
adapter: 'gemini',
permissionMode: session.permissionMode,
lastActivity: session.lastActivity || null,
hasClients: this._clientChecker ? this._clientChecker(sessionId) : false,
hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120_000)),
isNonInteractive: false,
firstPrompt: session.firstPrompt || null,
});
}
return result;
}
async hasActiveWindow(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) return false;
const windows = await tmuxManager.listWindows();
return windows.some(w => w.id === session.windowId);
}
isProcessing(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
return !!(session?.isProcessing);
}
/** Force an immediate JSON poll for a session */
flushMessages(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session?.watcher) session.watcher.pollNow();
}
/** Advance watcher past current file position without emitting messages */
syncWatcherPosition(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session?.watcher) session.watcher.markCurrentPosition();
}
/** Get pending state for reconnecting clients (tools, permissions, questions) */
getReconnectState(sessionId: string): ReconnectState {
const state: ReconnectState = { tools: {} as Record<string, import('../../types/messages.js').ToolStatus>, pendingRequests: [] };
for (const perm of this._permissions.getPendingForSession(sessionId)) {
state.pendingRequests.push({
type: 'permission',
requestId: perm.requestId,
toolName: perm.toolName,
input: perm.input,
});
}
for (const q of this._permissions.getQuestionsForSession(sessionId)) {
state.pendingRequests.push({
type: 'question',
requestId: q.requestId,
toolName: 'AskUserQuestion',
input: q.originalInput,
});
}
return state;
}
// === Permission Methods ===
respondPermission(requestId: string, behavior: PermissionBehavior): void {
const pending = this._permissions.resolvePermission(requestId);
if (!pending) return;
const session = this.sessions.get(pending.sessionId);
if (!session) return;
if (behavior === 'allow' || behavior === 'allow_session') {
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
} else {
tmuxManager.sendKeys(session.windowId, 'n', true).catch(() => {});
}
}
async respondQuestion(requestId: string, answer: string): Promise<void> {
const pending = this._permissions.resolveQuestion(requestId);
if (!pending) return;
const session = this.sessions.get(pending.sessionId);
if (!session) return;
await tmuxManager.sendKeys(session.windowId, answer, true);
}
/** Release all pending requests for a session */
releaseAllPending(sessionId: string): void {
this._permissions.dismissAll(sessionId);
}
resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior | string): void {
const resolvedIds = this._permissions.resolveAllAs(sessionId, behavior as string);
if (behavior === 'allow') {
const session = this.sessions.get(sessionId);
if (session) {
for (const _reqId of resolvedIds) {
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
}
}
}
}
// === Cleanup ===
async destroy(): Promise<void> {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
for (const [, session] of this.sessions) {
this._teardownSession(session);
}
this.sessions.clear();
await tmuxManager.killSession();
}
// === Internal Helpers ===
/** Map permission mode string to Gemini CLI --approval-mode value */
private _toCliApprovalMode(mode: string): string {
switch (mode) {
case 'yolo': return 'yolo';
case 'auto_edit': return 'auto_edit';
case 'plan': return 'plan';
default: return 'default';
}
}
/** Resolve hook body to internal session, touch lastActivity */
private _resolveAndTouch(body: GeminiHookBody): ResolvedContext | null {
const sessionId = body.session_id;
if (!sessionId) return null;
const session = this.sessions.get(sessionId);
if (!session) return null;
session.lastActivity = Date.now();
return { sessionId, session };
}
private _createSession(
windowId: string,
cwd: string,
cliSessionId: string,
permissionMode: string,
): GeminiSessionState {
return {
windowId,
monitor: null,
watcher: null,
parser: null,
cwd,
cliSessionId,
transcriptPath: null,
permissionMode,
lastActivity: Date.now(),
firstPrompt: null,
isProcessing: false,
_promptSenderClientId: null,
_watcherPending: true,
_matchRetryTimer: null,
};
}
/**
* Wait for Gemini CLI to be ready.
* Polls tmux pane content until a prompt indicator appears.
*/
private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise<void> {
const start = Date.now();
let attempt = 0;
while (Date.now() - start < timeoutMs) {
attempt++;
try {
const content = await tmuxManager.capturePane(windowId);
const lines = content.split('\n');
// Gemini shows > or similar prompt indicator
const hasPrompt = lines.some(l => /^\s*[>]/.test(l));
const lineCount = lines.filter(l => l.trim()).length;
if (attempt <= 3 || attempt % 5 === 0) {
console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
}
if (hasPrompt && lineCount >= 2) {
console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300));
return;
}
} catch (err) {
console.log(`[gemini-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
}
await new Promise<void>(r => setTimeout(r, 500));
}
console.warn(`[gemini-tmux] Timed out waiting for CLI ready on ${windowId}`);
}
/** Apply hook body state and start watcher — shared by all handleSessionStart branches */
private _applySessionStartBody(sessionId: string, body: GeminiHookBody): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (!session.cliSessionId) session.cliSessionId = body.session_id || '';
if (body.cwd) session.cwd = body.cwd;
if (body.model) {
// Emit initial model as status update
this.emit('status-update', sessionId, { model: body.model, tokens: null });
}
session.lastActivity = Date.now();
if (body.transcript_path && !session.transcriptPath) {
session.transcriptPath = body.transcript_path;
}
// Start JSON watcher if we have a transcript path and watcher isn't already running
if (session.transcriptPath && !session.watcher) {
const skipExisting = session.isProcessing !== false;
this._startWatcher(sessionId, session, skipExisting);
}
session._watcherPending = false;
}
/**
* Called after sendMessage when _pendingHookBodies has entries.
* Reads each pending hook body's transcript_path to find the CLAWTAP_REF marker.
*/
private async _tryMatchPending(tempKey: string): Promise<void> {
if (await this._scanPendingForMarker(tempKey)) return;
// Marker not found yet — Gemini may still be writing. Retry once after 2s.
const session = this.sessions.get(tempKey);
if (!session) return;
if (session._matchRetryTimer) clearTimeout(session._matchRetryTimer);
session._matchRetryTimer = setTimeout(async () => {
const s = this.sessions.get(tempKey);
if (!s || !s._watcherPending || !this._pendingHookBodies.size) return;
await this._scanPendingForMarker(tempKey);
}, 2000);
}
/** Scan _pendingHookBodies for a transcript containing CLAWTAP_REF:{tempKey}. */
private async _scanPendingForMarker(tempKey: string): Promise<boolean> {
for (const [uuid, body] of this._pendingHookBodies) {
if (!body.transcript_path) continue;
try {
const content = await readFile(body.transcript_path, 'utf8');
if (!content.includes(`CLAWTAP_REF:${tempKey}`)) continue;
console.log(`[gemini-tmux] Marker match: ${tempKey} -> ${uuid}`);
this._pendingHookBodies.delete(uuid);
this._rekeyAndRename(tempKey, uuid);
this._applySessionStartBody(uuid, body);
return true;
} catch { continue; }
}
return false;
}
/**
* Re-key a session from tempKey to the real CLI UUID and rename the tmux window.
*/
private _rekeyAndRename(tempKey: string, cliUuid: string): void {
const session = this.sessions.get(tempKey);
if (!session) return;
session.cliSessionId = cliUuid;
session._watcherPending = false;
this.sessions.delete(tempKey);
this.sessions.set(cliUuid, session);
tmuxManager.renameWindow(session.windowId, cliUuid).catch(() => {});
if (session.monitor) {
(session.monitor as any).sessionId = cliUuid;
}
// Notify session-manager to re-register clients under the new key
this.emit('session-rekeyed', tempKey, cliUuid);
}
private _startMonitor(sessionId: string, windowId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.monitor) {
session.monitor.stop();
}
const monitor = new GeminiPaneMonitor(sessionId, windowId, tmuxManager, this);
monitor.start();
session.monitor = monitor;
}
private _startWatcher(sessionId: string, session: GeminiSessionState, skipExisting = true): void {
if (!session.transcriptPath) return;
if (session.watcher) return;
const parser = new GeminiTranscriptParser();
const watcher = new JsonWatcher(session.transcriptPath);
watcher.onNewMessages((messages) => {
this._processWatcherMessages(sessionId, messages);
});
watcher.start({ skipExisting, fallbackIntervalMs: 1000 });
session.watcher = watcher;
session.parser = parser;
session._watcherPending = false;
}
private _teardownSession(session: GeminiSessionState): void {
if (session.monitor) {
session.monitor.stop();
session.monitor = null;
}
if (session.watcher) {
session.watcher.stop();
session.watcher = null;
session.parser = null;
}
if (session._matchRetryTimer) {
clearTimeout(session._matchRetryTimer);
session._matchRetryTimer = null;
}
}
private _startSessionCleanup(): void {
this._cleanupInterval = setInterval(async () => {
const windows = await tmuxManager.listWindows();
const liveWindowIds = new Set(windows.map(w => w.id));
for (const [sessionId, session] of this.sessions) {
if (session.windowId && !liveWindowIds.has(session.windowId)) {
console.log(`[gemini-tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
this._teardownSession(session);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
}
// Cap at 10 managed sessions
if (this.sessions.size > 10) {
const sorted = [...this.sessions.entries()]
.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
for (const [id] of sorted.slice(10)) {
const s = this.sessions.get(id);
if (s) this._teardownSession(s);
this.sessions.delete(id);
this.emit('session-ended', id);
}
}
// Clean up stale pending hook bodies (age-based sweep)
for (const [uuid, body] of this._pendingHookBodies) {
const age = Date.now() - body._storedAt;
if (age > 60_000) this._pendingHookBodies.delete(uuid);
}
}, 60_000);
this._cleanupInterval.unref();
}
}
+144
View File
@@ -0,0 +1,144 @@
// server/adapters/gemini/hook-config.ts
//
// Pure filesystem operations for Gemini hook management.
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
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));
/** Individual hook action (command or url based) */
interface HookAction {
type?: string;
command?: string;
url?: string;
timeout?: number;
}
/** A hook entry within a hook event */
interface HookEntry {
matcher?: string;
hooks: HookAction[];
}
/** The structure of Gemini's settings.json (partial) */
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 {
// Auto-detect from cert files
const clawtapDir = join(homedir(), '.clawtap');
this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem'));
}
}
/** Install ClawTap hooks into ~/.gemini/settings.json */
install(): void {
const port = this.port;
const settingsDir = join(homedir(), '.gemini');
const settingsPath = join(settingsDir, 'settings.json');
const protocol = this.useHttps ? 'https' : 'http';
const desiredHooks = this._buildDesiredHooks(protocol);
try {
mkdirSync(settingsDir, { recursive: true });
let existing: GeminiSettings = {};
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {}
// Replace our hooks on every startup.
// Preserves other tools' hooks by filtering only ClawTap entries.
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));
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}`);
}
}
/**
* Remove ClawTap hooks from ~/.gemini/settings.json.
* Leaves other user settings intact. Only removes hooks owned by this port.
*/
uninstall(): void {
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('http'));
for (const key of hookKeys) {
const entries = existing.hooks[key];
if (!Array.isArray(entries)) continue;
const filtered = entries.filter(entry => !this._isOurHookEntry(entry));
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 ClawTap 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}`);
}
}
// --- Internal helpers ---
private _isOurHookEntry(entry: HookEntry): boolean {
const hooks = entry.hooks || [];
return hooks.some(h =>
h.command != null && h.command.includes('bridge.sh') && h.command.includes(String(this.port))
);
}
private _buildDesiredHooks(protocol: string): Record<string, HookEntry[]> {
const port = this.port;
const bridgePath = resolve(__dirname, 'bridge.sh');
// Pass port and protocol as positional args (not env vars).
// Gemini CLI may use execFile instead of shell, so inline VAR=val doesn't work.
const mkCmd = (endpoint: string): string =>
`${bridgePath} ${endpoint} ${port} ${protocol}`;
// IMPORTANT: Gemini CLI timeout is in MILLISECONDS (not seconds like Claude Code).
// 5000ms = 5 seconds — enough for bridge.sh to read stdin, printf '{}', and background curl.
const timeout = 5000;
return {
SessionStart: [{ hooks: [{ type: 'command', command: mkCmd('session-start'), timeout }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: mkCmd('session-end'), timeout }] }],
BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('before-tool'), timeout }] }],
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('after-tool'), timeout }] }],
BeforeAgent: [{ hooks: [{ type: 'command', command: mkCmd('before-agent'), timeout }] }],
AfterAgent: [{ hooks: [{ type: 'command', command: mkCmd('after-agent'), timeout }] }],
};
}
}
+199
View File
@@ -0,0 +1,199 @@
// server/adapters/gemini/index.ts
import { IAdapter } from '../interface.js';
import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js';
import { GeminiTmuxAdapter } from './gemini-tmux-adapter.js';
import type { GeminiSessionState, GeminiHookBody } from './gemini-tmux-adapter.js';
import { GeminiHookConfig } from './hook-config.js';
import {
getSessions, getSessionMessages, listDirectory,
} from './json-store.js';
import { GeminiTranscriptParser } from './transcript-parser.js';
import type { QueryOptions, PermissionBehavior } from '../../types/messages.js';
import type { AdapterCapabilities, ModelInfo, PermissionModeInfo, EffortLevelInfo, ReconnectState, SessionInfo } from '../../types/adapter.js';
import type { Express } from 'express';
const MODELS: ModelInfo[] = [
{ value: 'auto', label: 'Auto' },
{ value: 'pro', label: 'Gemini Pro' },
{ value: 'flash', label: 'Gemini Flash' },
{ value: 'flash-lite', label: 'Flash Lite' },
];
const PERMISSION_MODES: PermissionModeInfo[] = [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'yolo', label: 'YOLO' },
{ value: 'plan', label: 'Plan' },
];
export class GeminiAdapter extends IAdapter {
static id: string = 'gemini';
static displayName: string = 'Gemini CLI';
static command: string = 'gemini';
private _tmux: GeminiTmuxAdapter;
private _hookConfig: GeminiHookConfig;
private _lastStatus: Map<string, CachedStatus>; // sessionId -> { contextPercent, model, cost }
constructor() {
super();
this._tmux = new GeminiTmuxAdapter();
this._hookConfig = new GeminiHookConfig();
this._lastStatus = new Map();
// Forward all events from internal tmux adapter
const events: string[] = [
'streaming-text', 'thinking', 'tool-start', 'tool-done',
'tool-updates', 'new-messages', 'session-idle',
'permission-request', 'ask-question', 'mode-changed',
'session-ended', 'session-error', 'compacting', 'compact-done',
'processing-started', 'session-rekeyed',
];
for (const event of events) {
this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args));
}
// Don't forward status-update blindly — deduplicate first
this._tmux.on('status-update', (sessionId: string, status: any) => {
const prev = this._lastStatus.get(sessionId);
if (prev &&
prev.contextPercent === status.contextPercent &&
prev.model === status.model &&
prev.cost === status.cost) return;
this._lastStatus.set(sessionId, status);
this.emit('status-update', sessionId, status);
});
// Clean up status dedup cache when session ends
this._tmux.on('session-ended', (sessionId: string) => {
this._lastStatus.delete(sessionId);
});
}
setup(app: Express): void {
this.installHooks();
this._registerHookRoutes(app);
}
installHooks(): void { this._hookConfig.install(); }
uninstallHooks(): void { this._hookConfig.uninstall(); }
async cleanup(): Promise<void> {
this.uninstallHooks();
await this._tmux.destroy();
}
/**
* Register Express routes for Gemini-specific hooks.
* These are called by the Gemini CLI bridge script from localhost (no auth needed).
*/
private _registerHookRoutes(app: Express): void {
// All hooks are fire-and-forget notifications — no return value used.
// Handlers are called for side effects only (emit events, update state).
const hookRoute = (path: string, handler: (body: GeminiHookBody) => void | Promise<void>): void => {
const label = path.split('/').pop();
app.post(path, (req: any, res: any) => {
const sid = req.body.session_id?.substring(0, 8) || '?';
const toolInfo = req.body.tool_name ? ` ${req.body.tool_name}` : '';
console.log(`[hook] ${label}:${toolInfo} sid=${sid}`);
try {
const result = handler(req.body);
if (result instanceof Promise) result.catch((e: Error) => console.error(`[hook] ${label} error:`, e.message));
} catch (e) { console.error(`[hook] ${label} error:`, (e as Error).message); }
res.json({});
});
};
const prefix = this.getHookPrefix(); // /api/hooks/gemini
hookRoute(`${prefix}/session-start`, (body) => {
this._tmux.handleSessionStart(body);
});
hookRoute(`${prefix}/session-end`, (body) => {
this._tmux.handleSessionEnd(body);
});
hookRoute(`${prefix}/before-tool`, (body) => {
this._tmux.handleBeforeTool(body);
});
hookRoute(`${prefix}/after-tool`, (body) => {
this._tmux.handleAfterTool(body);
});
hookRoute(`${prefix}/before-agent`, (body) => {
this._tmux.handleBeforeAgent(body);
});
hookRoute(`${prefix}/after-agent`, (body) => {
this._tmux.handleAfterAgent(body);
});
}
setClientChecker(fn: (sessionId: string) => boolean): void {
this._tmux.setClientChecker(fn);
}
// Lifecycle — delegate to tmux adapter
async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> {
return this._tmux.startSession(cwd, options);
}
async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> {
return this._tmux.resumeSession(sid, cwd, options);
}
async attachSession(_sid: string, _cwd: string, _options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Gemini does not support attach'); }
async destroySession(sid: string): Promise<void> { return this._tmux.destroySession(sid); }
async sendMessage(sid: string, text: string, options?: QueryOptions): Promise<void> { return this._tmux.sendMessage(sid, text, options); }
async switchModel(sid: string, model: string): Promise<void> { return this._tmux.switchModel(sid, model); }
async interrupt(sid: string): Promise<void> { return this._tmux.interrupt(sid); }
flushMessages(sid: string): void { this._tmux.flushMessages(sid); }
syncWatcherPosition(sid: string): void { this._tmux.syncWatcherPosition(sid); }
getReconnectState(sid: string): ReconnectState { return this._tmux.getReconnectState(sid); }
// Store — delegate to json-store, parse through transcript parser for getMessages
async getSessions(dir?: string, limit?: number): Promise<SessionInfo[]> { return getSessions(dir, limit); }
async getMessages(sid: string, dir?: string): Promise<MessagesResult> {
const { messages: rawMessages, lastModified } = getSessionMessages(sid, dir);
if (rawMessages.length === 0) return { messages: [], lastModified };
// Parse raw Gemini messages through the transcript parser
const parser = new GeminiTranscriptParser();
const { messages } = parser.parse(rawMessages as import('../../stores/json-watcher.js').GeminiSessionMessage[]);
return { messages, lastModified };
}
async listDirectory(path?: string): Promise<DirectoryEntry[]> { return listDirectory(path); }
// Permissions — delegate to tmux adapter
async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); }
respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); }
async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); }
releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); }
resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); }
// Query
isProcessing(sid: string): boolean { return this._tmux.isProcessing(sid); }
getSession(sid: string): GeminiSessionState | undefined { return this._tmux.getSession(sid); }
getLastStatus(sid: string) { return this._lastStatus.get(sid) || null; }
async hasActiveWindow(sid: string): Promise<boolean> { return this._tmux.hasActiveWindow(sid); }
getActiveSessions(): ActiveSessionInfo[] { return this._tmux.getActiveSessions(); }
// Capabilities
getModels(): ModelInfo[] { return MODELS; }
getPermissionModes(): PermissionModeInfo[] { return PERMISSION_MODES; }
getEffortLevels(): EffortLevelInfo[] { return []; }
getCapabilities(): AdapterCapabilities {
return {
supportsPlanMode: true,
supportsPermissionModes: true,
supportsInterrupt: true,
supportsResume: true,
supportsAttach: false,
supportsStatusLine: false,
supportsImages: true,
supportsStreaming: true,
maxContextWindow: 1_000_000,
permissionModeType: 'toggle',
};
}
}
+299
View File
@@ -0,0 +1,299 @@
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { extractUserText } from './message-utils.js';
import type { DirectoryEntry } from '../interface.js';
import type { SessionInfo } from '../../types/adapter.js';
// --- Constants ---
export const GEMINI_DIR: string = join(homedir(), '.gemini');
export const GEMINI_TMP_DIR: string = join(GEMINI_DIR, 'tmp');
export const GEMINI_PROJECTS_FILE: string = join(GEMINI_DIR, 'projects.json');
// --- Types ---
interface GeminiProjectsFile {
projects?: Record<string, string>;
}
interface GeminiSessionFile {
sessionId?: string;
startTime?: string;
lastUpdated?: string;
messages?: unknown[];
summary?: string;
}
interface GeminiMessage {
type?: string; // 'user' | 'gemini' | 'error' | 'info' | 'warning'
content?: unknown;
model?: string;
}
// --- Helpers ---
function readProjectsJson(): GeminiProjectsFile {
try {
const raw = readFileSync(GEMINI_PROJECTS_FILE, 'utf-8');
return JSON.parse(raw) as GeminiProjectsFile;
} catch {
return {};
}
}
// --- Exported Functions ---
/**
* Look up the Gemini project name for a given absolute directory path.
* Reads ~/.gemini/projects.json which maps "/abs/path" → "project-name".
*/
export function getProjectName(dir: string): string | null {
try {
const data = readProjectsJson();
return data.projects?.[dir] ?? null;
} catch {
return null;
}
}
/**
* List all project directories under ~/.gemini/tmp/.
* If dir is provided, only return the matching project directory.
*/
function getProjectDirs(dir?: string): Array<{ projectDir: string; cwd: string | null }> {
if (dir) {
const projectName = getProjectName(dir);
if (!projectName) return [];
return [{ projectDir: join(GEMINI_TMP_DIR, projectName), cwd: dir }];
}
try {
const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory())
.map((e) => {
const projectDir = join(GEMINI_TMP_DIR, e.name);
// Attempt to read .project_root for the cwd
let cwd: string | null = null;
try {
cwd = readFileSync(join(projectDir, '.project_root'), 'utf-8').trim() || null;
} catch {
// no .project_root — cwd stays null
}
return { projectDir, cwd };
});
} catch {
return [];
}
}
/**
* List sessions for a project (or all projects if dir is omitted).
* Returns SessionInfo[] sorted by lastModified descending.
*/
export function getSessions(dir?: string, limit?: number): SessionInfo[] {
const projectDirs = getProjectDirs(dir);
const sessions: SessionInfo[] = [];
for (const { projectDir, cwd } of projectDirs) {
const chatsDir = join(projectDir, 'chats');
let files: string[];
try {
files = readdirSync(chatsDir);
} catch {
continue;
}
const jsonFiles = files.filter((f) => f.endsWith('.json'));
for (const file of jsonFiles) {
const filePath = join(chatsDir, file);
try {
const raw = readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as GeminiSessionFile;
const sessionId = data.sessionId ?? file.replace('.json', '');
const lastModified = data.lastUpdated ?? data.startTime ?? null;
// Extract firstPrompt from first user message
let firstPrompt: string | null = null;
if (Array.isArray(data.messages)) {
for (const msg of data.messages) {
const m = msg as GeminiMessage;
if (m.type === 'user' && m.content != null) {
const text = extractUserText(m.content);
if (text.trim()) {
firstPrompt = text.slice(0, 200);
break;
}
}
}
}
// Extract model from last gemini message
let model: string | null = null;
if (Array.isArray(data.messages)) {
for (let i = data.messages.length - 1; i >= 0; i--) {
const m = data.messages[i] as GeminiMessage;
if (m.type === 'gemini' && m.model) {
model = m.model;
break;
}
}
}
sessions.push({
sessionId,
cwd,
lastModified: lastModified ?? undefined,
firstPrompt,
model,
});
} catch {
// skip malformed files
}
}
}
// Sort by lastModified descending, null-safe
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 limit ? sessions.slice(0, limit) : sessions;
}
/**
* Find the absolute path of a session file by UUID across all project dirs.
* Returns null if not found.
*/
export function findSessionFile(sessionId: string): string | null {
let projectDirs: string[];
try {
const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true });
projectDirs = entries
.filter((e) => e.isDirectory())
.map((e) => join(GEMINI_TMP_DIR, e.name));
} catch {
return null;
}
for (const projectDir of projectDirs) {
const chatsDir = join(projectDir, 'chats');
let files: string[];
try {
files = readdirSync(chatsDir);
} catch {
continue;
}
for (const file of files) {
if (!file.endsWith('.json')) continue;
const filePath = join(chatsDir, file);
// Fast path: check if session UUID appears in filename before parsing
if (file.includes(sessionId)) return filePath;
// Slow path: parse JSON to check sessionId field
try {
const raw = readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as GeminiSessionFile;
if (data.sessionId === sessionId) return filePath;
} catch {
// skip malformed files
}
}
}
return null;
}
/**
* Read all messages from a session file.
* If dir is provided, search only in that project's chats dir.
*/
export function getSessionMessages(
sessionId: string,
dir?: string
): { messages: unknown[]; lastModified: string | null } {
// Determine candidate file paths
let filePath: string | null = null;
if (dir) {
const projectName = getProjectName(dir);
if (projectName) {
const chatsDir = join(GEMINI_TMP_DIR, projectName, 'chats');
// Try exact match first, then scan
try {
const files = readdirSync(chatsDir);
for (const file of files) {
if (!file.endsWith('.json')) continue;
const fp = join(chatsDir, file);
try {
const raw = readFileSync(fp, 'utf-8');
const data = JSON.parse(raw) as GeminiSessionFile;
const fileSessionId = data.sessionId ?? file.replace('.json', '');
if (fileSessionId === sessionId) {
filePath = fp;
break;
}
} catch {
// skip
}
}
} catch {
// chats dir not readable
}
}
} else {
filePath = findSessionFile(sessionId);
}
if (!filePath) return { messages: [], lastModified: null };
try {
const raw = readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as GeminiSessionFile;
const messages = Array.isArray(data.messages) ? data.messages : [];
let lastModified: string | null = null;
try {
const s = statSync(filePath);
lastModified = s.mtime.toISOString();
} catch {
lastModified = data.lastUpdated ?? data.startTime ?? null;
}
return { messages, lastModified };
} catch {
return { messages: [], lastModified: null };
}
}
/**
* List directory entries (non-hidden subdirectories) for the directory browser.
* If path is omitted, defaults to the user's home directory.
*/
export function listDirectory(dirPath?: string): DirectoryEntry[] {
const target = dirPath || homedir();
try {
const entries = readdirSync(target, { withFileTypes: true });
const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
const dirs: DirectoryEntry[] = visible.map((entry) => {
const fullPath = join(target, entry.name);
let hasChildren = false;
try {
const children = readdirSync(fullPath, { withFileTypes: true });
hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.'));
} catch {
// no access
}
return { name: entry.name, path: fullPath, hasChildren };
});
return dirs.sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { ContentBlock } from '../claude/message-utils.js';
export type { ContentBlock };
/** A tool call embedded in a Gemini session message */
export interface GeminiToolCall {
id: string;
name: string;
args: Record<string, unknown>;
result?: unknown[];
status: 'running' | 'success' | 'error' | 'cancelled';
timestamp?: string;
displayName?: string;
description?: string;
}
/**
* Extract plain text from a user message's content.
* Gemini user content is either an array of { text } objects or a plain string.
*/
export function extractUserText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((item): item is { text: string } => item != null && typeof item.text === 'string')
.map((item) => item.text)
.join('\n');
}
return '';
}
/** Alias — Gemini assistant content is always a string, but use same extractor for safety */
export const extractGeminiText = extractUserText;
/**
* Convert Gemini's embedded toolCalls array into standard tool_use + tool_result ContentBlock pairs.
*
* Each GeminiToolCall becomes:
* - A tool_use block (id, name, input = args)
* - Optionally a tool_result block if a result is present (tool_use_id, content, is_error)
*/
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 — only if result data is present
if (tc.result !== undefined && tc.result !== null) {
const isError = tc.status === 'error' || tc.status === 'cancelled';
// Extract the output or error string from the function response structure
let resultContent = '';
if (Array.isArray(tc.result) && tc.result.length > 0) {
const firstResult = tc.result[0] as Record<string, unknown> | null;
const functionResponse = firstResult?.functionResponse as Record<string, unknown> | undefined;
const response = functionResponse?.response as Record<string, unknown> | undefined;
if (isError) {
resultContent = typeof response?.error === 'string'
? response.error
: JSON.stringify(response?.error ?? '');
} else {
resultContent = typeof response?.output === 'string'
? response.output
: JSON.stringify(response?.output ?? '');
}
}
blocks.push({
type: 'tool_result',
tool_use_id: tc.id,
content: resultContent,
is_error: isError,
});
}
}
return blocks;
}
+217
View File
@@ -0,0 +1,217 @@
// server/adapters/gemini/pane-monitor.ts
//
// Polls a tmux pane every 500ms to capture real-time streaming output from
// the Gemini CLI.
//
// Detects:
// 1. Streaming response text (new text since last poll)
// 2. Thinking indicators (spinner / processing patterns)
//
// Note: Gemini already provides thinking content in JSON (thoughts[]), so
// pane-level thinking detection is supplementary — it provides real-time
// feedback before the JSON response is written to disk.
//
// Modelled after server/adapters/codex/pane-monitor.ts but with
// Gemini-specific regex patterns. Patterns are conservative placeholders
// that will be refined through empirical testing with the actual Gemini CLI.
import { EventEmitter } from 'events';
/** Minimal interface for the tmux manager dependency */
interface TmuxCapture {
capturePane(windowId: string, lines?: number): Promise<string>;
}
/** Thinking indicator detected from pane content */
export interface ThinkingInfo {
text: string;
detail: string | null;
}
/**
* GeminiPaneMonitor — polls a tmux pane to detect streaming text and
* thinking indicators from the Gemini CLI.
*
* Events emitted via the injected EventEmitter:
* - 'streaming-text' (sessionId, newText)
* - 'thinking' (sessionId, { text, detail })
*/
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;
}
/** Begin polling the tmux pane at 500ms intervals */
start(): void {
if (this.interval) return;
this.interval = setInterval(() => this._poll(), 500);
}
/** Stop polling and clear the interval */
stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/** Force an immediate poll (useful on hook receipt) */
async pollNow(): Promise<void> {
await this._poll();
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
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 unit testing)
// =============================================================================
/**
* Detect Gemini thinking/processing indicators.
*
* Gemini CLI shows various spinner/processing patterns while reasoning.
* In non-alt-screen mode these appear as inline text in the pane.
*
* Placeholder patterns — will be refined through empirical testing:
* - "Thinking..." text (Gemini's native thinking label)
* - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set)
* - "Generating..." or processing indicators
*/
export function detectThinking(content: string): ThinkingInfo | null {
const lines = content.split('\n');
// Only check the tail of the pane (last 15 lines)
const tail = lines.slice(-15);
for (const line of tail) {
// Skip completion/summary lines
if (/completed|finished|done|exited/i.test(line)) continue;
// Pattern 1: Braille spinner followed by descriptive text
// e.g. "⠙ Thinking..." or "⠹ Generating..."
const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/);
if (brailleMatch) {
return { text: brailleMatch[2]!, detail: null };
}
// Pattern 2: Explicit "Thinking..." or "Generating..." text
// Gemini CLI commonly shows "Thinking..." during reasoning
const thinkingMatch = line.match(
/^\s*(Thinking|Generating|Processing|Working)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i,
);
if (thinkingMatch) {
return {
text: `${thinkingMatch[1]}...`,
detail: thinkingMatch[3] || null,
};
}
// Pattern 3: Braille spinner on its own (Gemini may render bare spinner)
const bareSpinner = line.match(/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*$/);
if (bareSpinner) {
return { text: 'Thinking...', detail: null };
}
}
return null;
}
/**
* Extract the current streaming response text from pane content.
*
* Gemini CLI writes responses inline. We look for text after the last
* user input marker and collect lines until we hit a boundary indicator.
*
* Placeholder patterns — will be refined through empirical testing:
* - User input prompt: ">" or "" followed by user text
* - Response boundary: horizontal rules, new prompts, spinner indicators
*/
export function extractResponseText(content: string): string {
const lines = content.split('\n');
// Find the LAST user prompt line — responses appear after it
let lastUserPrompt = -1;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
// Gemini user prompt patterns (conservative):
// - ">" or "" at start of line followed by user text
// - "user:" prefix
if (/^\s*[>]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) {
lastUserPrompt = i;
break;
}
}
if (lastUserPrompt === -1) return '';
// Collect response lines after the user prompt
// Skip the prompt line itself and any blank lines immediately after
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]!;
// Stop at boundary markers
if (
// Horizontal rules
/^[─━═\-]{5,}/.test(line.trim()) ||
// New user prompt
/^\s*[>]\s+\S/.test(line) ||
// Spinner/thinking indicators (braille set)
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/.test(line)
) {
break;
}
responseLines.push(line);
}
return responseLines.join('\n').trim();
}
+175
View File
@@ -0,0 +1,175 @@
import type { ContentBlock } from '../claude/message-utils.js';
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
import {
extractUserText,
extractGeminiText,
toolCallsToContentBlocks,
} from './message-utils.js';
import type { GeminiToolCall } from './message-utils.js';
/** Parsed message ready for the frontend */
export interface ParsedMessage {
id: string;
role: 'user' | 'assistant' | 'plan';
/** Always ContentBlock[] — never a plain string, for consistency with Claude/Codex */
content: ContentBlock[];
adapter?: string;
senderClientId?: string | null;
}
/** Result returned by parse() */
export interface ParseResult {
messages: ParsedMessage[];
errors: string[];
}
/** Model/token status extracted from an info message */
export interface StatusInfo {
model: string | null;
tokens: Record<string, number> | null;
}
/** A single thought entry from a gemini message */
export interface ThoughtEntry {
subject: string;
description: string;
timestamp: string;
}
export class GeminiTranscriptParser {
/** Monotonically increasing message index — NOT reset between parse() calls */
private _msgIndex: number = 0;
/**
* Parse an incremental batch of GeminiSessionMessages into frontend-ready ParsedMessages.
*
* NOTE: _msgIndex is intentionally NOT reset here. parse() is called incrementally via
* JsonWatcher.onNewMessages(). Resetting would restart IDs at msg-0, causing React key
* collisions across batches.
*/
parse(messages: GeminiSessionMessage[]): ParseResult {
const result: ParsedMessage[] = [];
const errors: string[] = [];
for (const msg of messages) {
switch (msg.type) {
case 'user': {
const parsed = this._parseUserMessage(msg);
if (parsed) result.push(parsed);
break;
}
case 'gemini': {
const parsed = this._parseGeminiMessage(msg);
if (parsed) result.push(parsed);
break;
}
case 'error': {
// Collect errors to emit as session-error events; don't add as chat messages
const errText = extractUserText(msg.content) || String(msg.content ?? 'Unknown error');
errors.push(errText);
break;
}
case 'info':
// Info messages carry metadata (model, tokens) — skip as chat messages
break;
default:
// Unknown type — skip silently
break;
}
}
return { messages: result, errors };
}
/**
* Extract thought entries from a gemini message's thoughts array.
* Returns an empty array if none are present.
*/
static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] {
if (!Array.isArray(msg.thoughts) || msg.thoughts.length === 0) return [];
return msg.thoughts
.filter((t): t is Record<string, unknown> => t != null && typeof t === 'object')
.map((t) => ({
subject: typeof t['subject'] === 'string' ? t['subject'] : '',
description: typeof t['description'] === 'string' ? t['description'] : '',
timestamp: typeof t['timestamp'] === 'string' ? t['timestamp'] : (msg.timestamp ?? ''),
}));
}
/**
* Extract model/token status from an info-type message.
* Returns null if the message carries no relevant status data.
*/
static extractStatus(msg: GeminiSessionMessage): StatusInfo | null {
const model = msg.model ?? null;
let tokens: Record<string, number> | null = null;
if (msg.tokens && typeof msg.tokens === 'object') {
const raw = msg.tokens as Record<string, unknown>;
const parsed: Record<string, number> = {};
for (const [key, val] of Object.entries(raw)) {
if (typeof val === 'number') parsed[key] = val;
}
if (Object.keys(parsed).length > 0) tokens = parsed;
}
if (model === null && tokens === null) return null;
return { model, tokens };
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private _parseUserMessage(msg: GeminiSessionMessage): ParsedMessage | null {
const text = extractUserText(msg.content);
if (!text.trim()) return null;
const content: ContentBlock[] = [{ type: 'text', text }];
return {
id: `msg-${this._msgIndex++}`,
role: 'user',
content,
adapter: 'gemini',
};
}
private _parseGeminiMessage(msg: GeminiSessionMessage): ParsedMessage | null {
const text = extractGeminiText(msg.content);
const toolBlocks = this._extractToolBlocks(msg);
// Skip completely empty messages
if (!text.trim() && toolBlocks.length === 0) return null;
const content: ContentBlock[] = [];
// Text block first (if present)
if (text.trim()) {
content.push({ type: 'text', text });
}
// Tool call blocks after the text
content.push(...toolBlocks);
return {
id: `msg-${this._msgIndex++}`,
role: 'assistant',
content,
adapter: 'gemini',
};
}
private _extractToolBlocks(msg: GeminiSessionMessage): ContentBlock[] {
if (!Array.isArray(msg.toolCalls) || msg.toolCalls.length === 0) return [];
const toolCalls = (msg.toolCalls as unknown[]).filter(
(tc): tc is GeminiToolCall =>
tc != null &&
typeof tc === 'object' &&
typeof (tc as Record<string, unknown>)['id'] === 'string' &&
typeof (tc as Record<string, unknown>)['name'] === 'string',
);
return toolCallsToContentBlocks(toolCalls);
}
}