Files
clawtap/server/adapters/gemini/gemini-tmux-adapter.ts
T
kuannnn 0fcf66fc22 feat: ClawTap v0.2.0
Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:46:00 +08:00

934 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { findActiveSession } from '../shared/find-active-session.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; pendingRekey?: boolean }> {
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()}`;
console.log(`[gemini-tmux] startSession: tempName=${tempName} cwd=${cwd} mode=${mode}`);
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;
console.log(`[gemini-tmux] startSession: rekeyed ${tempName}${rekeyed}`);
} else {
console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`);
}
}
console.log(`[gemini-tmux] startSession: finalId=${finalId} pendingRekey=${finalId === tempName}`);
this._startMonitor(finalId, windowId);
return { sessionId: finalId, pendingRekey: finalId === tempName };
}
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);
}
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
const pending = this._permissions.resolvePermission(requestId)
|| this._permissions.resolveQuestion(requestId);
// For pane-monitor-detected prompts, there may be no pending entry — find session from active sessions
const sessionId = pending?.sessionId || findActiveSession(this.sessions);
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
if (selectedOption != null) {
const index = parseInt(selectedOption);
if (!isNaN(index)) {
// Gemini numbered options: navigate Down × index, then Enter
this._selectNumberedOption(session.windowId, index).catch(() => {});
}
}
if (textValue != null) {
tmuxManager.sendKeys(session.windowId, textValue, true).catch(() => {});
}
}
/** 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(() => {});
}
}
}
}
private async _selectNumberedOption(windowId: string, targetIndex: number): Promise<void> {
for (let i = 0; i < targetIndex; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
}
await tmuxManager.sendControl(windowId, 'Enter');
}
// === 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 = 60000): 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 > (default), * (yolo), or as prompt indicator
const hasPrompt = lines.some(l => /^\s*[>*]/.test(l));
if (hasPrompt) {
console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300));
return;
}
// Privacy Notice or Terms of Service popup — dismiss with Esc
if (!hasPrompt && (content.includes('Privacy Notice') ||
(content.includes('Terms of Service') && !content.includes('trust the files')))) {
console.log('[gemini-tmux] Privacy/ToS notice detected, dismissing');
await tmuxManager.sendControl(windowId, 'Escape');
await new Promise<void>(r => setTimeout(r, 500));
continue;
}
// Multi-folder trust dialog ("trust the following folders")
if (!hasPrompt && content.includes('trust the following folders')) {
console.log('[gemini-tmux] Multi-folder trust detected, accepting');
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
// IDE integration nudge — decline
if (!hasPrompt && content.includes('Do you want to connect') && content.includes('Gemini CLI')) {
console.log('[gemini-tmux] IDE nudge detected, declining');
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 500));
continue;
}
// Auto-accept folder trust prompt (Gemini asks on first use in a directory).
// Only runs when prompt is NOT yet visible.
if (content.includes('trust the files') && content.includes('Trust folder')) {
const parentMatch = content.match(/(\d+)\.\s+Trust parent folder/);
if (parentMatch) {
const targetOption = parseInt(parentMatch[1]);
console.log(`[gemini-tmux] Folder trust prompt detected, selecting option ${targetOption} (Trust parent folder)`);
for (let i = 1; i < targetOption; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
}
} else {
console.log(`[gemini-tmux] Folder trust prompt detected, accepting default (Trust folder)`);
}
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
if (attempt <= 3 || attempt % 5 === 0) {
const lineCount = lines.filter(l => l.trim()).length;
console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
if (lineCount > 0) {
const nonEmpty = lines.filter(l => l.trim());
console.log(`[gemini-tmux] waitForReady content: first="${nonEmpty[0]?.substring(0, 60)}" last="${nonEmpty[nonEmpty.length - 1]?.substring(0, 60)}"`);
}
}
} 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();
}
}