0fcf66fc22
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>
933 lines
35 KiB
TypeScript
933 lines
35 KiB
TypeScript
import { EventEmitter } from 'events';
|
||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||
import type { TmuxWindow } from '../shared/tmux-manager.js';
|
||
import { PaneMonitor } from './pane-monitor.js';
|
||
import { JsonlWatcher } from '../../stores/jsonl-watcher.js';
|
||
import { TranscriptParser } from './transcript-parser.js';
|
||
import type { ParsedMessage } from './transcript-parser.js';
|
||
import { readdir, stat } from 'fs/promises';
|
||
import { join } from 'path';
|
||
import crypto from 'crypto';
|
||
import { PROJECTS_DIR, encodeDirName, parseSessionHeader } from './jsonl-store.js';
|
||
import { extractText } from './message-utils.js';
|
||
import type { JsonlEntry } from './message-utils.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 { PLAN_OPTION } from '../../ws-types.js';
|
||
|
||
const MODE_CYCLE: string[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
||
/** Internal session state for a managed tmux session */
|
||
export interface SessionState {
|
||
windowId: string;
|
||
monitor: PaneMonitor | null;
|
||
watcher: JsonlWatcher | null;
|
||
parser: TranscriptParser | null;
|
||
cwd: string;
|
||
cliSessionId: string;
|
||
permissionMode: string;
|
||
lastActivity: number;
|
||
firstPrompt: string | null;
|
||
isProcessing: boolean;
|
||
isNonInteractive: boolean;
|
||
_interactiveChecked: boolean;
|
||
_promptSenderClientId: string | null;
|
||
_modeTransitionDeadline: number;
|
||
_watcherPending: boolean;
|
||
}
|
||
|
||
/** Hook body payload from Claude CLI */
|
||
export interface HookBody {
|
||
session_id?: string;
|
||
permission_mode?: string;
|
||
tool_use_id?: string;
|
||
tool_name?: string;
|
||
tool_input?: Record<string, unknown>;
|
||
tool_response?: unknown;
|
||
error?: string;
|
||
error_details?: string;
|
||
is_interrupt?: boolean;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
/** Resolved session context from _resolveAndTouch */
|
||
interface ResolvedContext {
|
||
sessionId: string;
|
||
session: SessionState | undefined;
|
||
}
|
||
|
||
/**
|
||
* TmuxAdapter — manages Claude Code sessions via tmux.
|
||
*
|
||
* Three channels provide events to the SessionManager:
|
||
* 1. HTTP Hooks (structured): tool-start, tool-done, session-idle, permission-request
|
||
* 2. JSONL Watcher (messages): new-messages (single source of truth)
|
||
* 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)
|
||
* session-error(sessionId, { errorType, errorDetails })
|
||
* permission-request(sessionId, { requestId, toolName, input })
|
||
* ask-question(sessionId, { requestId, toolName, input })
|
||
* mode-changed(sessionId, mode)
|
||
* session-ended(sessionId)
|
||
* compacting(sessionId)
|
||
* compact-done(sessionId)
|
||
* processing-started(sessionId)
|
||
*/
|
||
export class TmuxAdapter extends EventEmitter {
|
||
// sessionId (CLI UUID) -> { windowId, monitor, watcher, parser, cwd, cliSessionId, permissionMode }
|
||
sessions: Map<string, SessionState>;
|
||
// Centralized pending permissions/questions manager
|
||
private _permissions: PermissionManager;
|
||
// Set by SessionManager to check if WS clients are connected
|
||
private _clientChecker: ((sessionId: string) => boolean) | null;
|
||
private _cleanupInterval: ReturnType<typeof setInterval> | null;
|
||
|
||
// CLI permission prompt option layout (Claude CLI v2.x):
|
||
// 0: "Yes"
|
||
// 1: "Yes, allow all edits during this session (shift+tab)"
|
||
// 2: "No"
|
||
static PERMISSION_DENY_INDEX: number = 2;
|
||
|
||
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 }> {
|
||
// Generate UUID upfront — no guessing needed
|
||
const cliSessionId = crypto.randomUUID();
|
||
|
||
const mode = options.permissionMode || 'default';
|
||
const parts = ['claude', '--session-id', cliSessionId];
|
||
// Always start with bypass so all 4 modes are reachable mid-session via Shift+Tab
|
||
parts.push('--dangerously-skip-permissions');
|
||
if (options.model) parts.push('--model', `'${options.model}'`);
|
||
if (options.effort) parts.push('--effort', options.effort);
|
||
|
||
const sessionId = cliSessionId;
|
||
const windowId = await tmuxManager.createWindow(sessionId, cwd, parts.join(' '));
|
||
|
||
// Register session BEFORE _waitForReady — SessionStart hook fires during the wait,
|
||
// and needs the session in the Map to avoid creating a duplicate session/watcher.
|
||
this.sessions.set(sessionId, this._createSession(windowId, cwd, cliSessionId, mode));
|
||
|
||
await this._waitForReady(windowId);
|
||
|
||
this._startMonitor(sessionId, windowId);
|
||
this._ensureWatcher(sessionId);
|
||
|
||
// Switch to user's desired mode (if not already bypassPermissions)
|
||
if (mode && mode !== 'bypassPermissions') {
|
||
await this.switchPermissionMode(sessionId, mode);
|
||
}
|
||
|
||
return { sessionId };
|
||
}
|
||
|
||
async attachSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
|
||
const existing = this.sessions.get(sessionId);
|
||
|
||
// If already attached with a watcher, don't recreate
|
||
if (existing?.watcher) {
|
||
if (!existing.monitor) this._startMonitor(sessionId, existing.windowId);
|
||
if (options.permissionMode) existing.permissionMode = options.permissionMode;
|
||
return { sessionId };
|
||
}
|
||
|
||
const windowId = await this._findWindowForSession(sessionId);
|
||
if (!windowId) throw new Error(`No tmux window found for session ${sessionId}`);
|
||
|
||
// Defensive: if another session already manages this tmux window,
|
||
// redirect to it instead of creating a duplicate entry.
|
||
// Each tmux window runs exactly one Claude CLI — same window = same session.
|
||
if (!existing) {
|
||
for (const [existingId, existingSession] of this.sessions) {
|
||
if (existingSession.windowId === windowId) {
|
||
if (!existingSession.monitor) this._startMonitor(existingId, windowId);
|
||
return { sessionId: existingId };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Preserve existing watcher/parser if session entry exists
|
||
if (existing) {
|
||
existing.windowId = windowId;
|
||
existing.lastActivity = Date.now();
|
||
if (options.permissionMode) existing.permissionMode = options.permissionMode;
|
||
if (!existing.monitor) this._startMonitor(sessionId, windowId);
|
||
} else {
|
||
this.sessions.set(sessionId, this._createSession(windowId, cwd, sessionId, options.permissionMode || 'default'));
|
||
this._startMonitor(sessionId, windowId);
|
||
}
|
||
|
||
await this._ensureWatcher(sessionId);
|
||
return { sessionId };
|
||
}
|
||
|
||
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
|
||
const mode = options.permissionMode || 'default';
|
||
const windows = await tmuxManager.listWindows();
|
||
|
||
// Extract CLI UUID before potentially deleting the session
|
||
const existingSession = this.sessions.get(sessionId);
|
||
const cliUuid = existingSession?.cliSessionId || sessionId;
|
||
|
||
// Check if session already managed and tmux window still exists
|
||
if (existingSession) {
|
||
if (await this._windowExists(existingSession.windowId, windows)) {
|
||
if (!existingSession.monitor) this._startMonitor(sessionId, existingSession.windowId);
|
||
existingSession.permissionMode = mode;
|
||
existingSession.lastActivity = Date.now();
|
||
await this._ensureWatcher(sessionId);
|
||
return { sessionId };
|
||
}
|
||
// Window gone — stop old watcher before replacing
|
||
this._teardownSession(existingSession);
|
||
this.sessions.delete(sessionId);
|
||
}
|
||
|
||
// Check for existing tmux window (e.g., started from Desktop)
|
||
const existingWindowId = await this._findWindowForSession(cliUuid, windows);
|
||
if (existingWindowId) {
|
||
return this.attachSession(sessionId, cwd, options);
|
||
}
|
||
|
||
// No existing window — create new with --resume
|
||
const modeFlag = '--dangerously-skip-permissions';
|
||
let command = `claude ${modeFlag} --resume ${cliUuid}`;
|
||
if (options.effort) command += ` --effort ${options.effort}`;
|
||
const newSessionId = cliUuid;
|
||
const windowId = await tmuxManager.createWindow(cliUuid, cwd || process.cwd(), command);
|
||
|
||
// Register before _waitForReady (same pattern as startSession)
|
||
this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, mode));
|
||
|
||
await this._waitForReady(windowId);
|
||
|
||
this._startMonitor(newSessionId, windowId);
|
||
await this._ensureWatcher(newSessionId);
|
||
return { sessionId: newSessionId };
|
||
}
|
||
|
||
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;
|
||
// Restart pane monitor if it was stopped (e.g., after turn-complete)
|
||
if (!session.monitor) {
|
||
this._startMonitor(sessionId, session.windowId);
|
||
}
|
||
if (isLargeContent(text)) {
|
||
// Large/multiline content: use pasteBuffer for speed.
|
||
// Claude CLI handles multiline input natively — no \n replacement needed.
|
||
// pasteBuffer defaults sendEnter=true, so Enter is sent automatically.
|
||
await tmuxManager.pasteBuffer(session.windowId, text);
|
||
} else {
|
||
await tmuxManager.sendKeys(session.windowId, text, true);
|
||
}
|
||
}
|
||
|
||
async switchModel(sessionId: string, model: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
await tmuxManager.sendKeys(session.windowId, `/model ${model}`, true);
|
||
}
|
||
|
||
async interrupt(sessionId: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
await tmuxManager.sendControl(session.windowId, 'C-c');
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
getSession(sessionId: string): SessionState | undefined {
|
||
return this.sessions.get(sessionId);
|
||
}
|
||
|
||
/** Force an immediate JSONL 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 entries */
|
||
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 session = this.sessions.get(sessionId);
|
||
const state: ReconnectState = { tools: {}, pendingRequests: [] };
|
||
|
||
if (session?.parser) {
|
||
const tools = session.parser.getPendingTools();
|
||
if (tools.size > 0) {
|
||
// PendingTool is a superset of ToolStatus — cast is safe for reconnect replay
|
||
state.tools = Object.fromEntries(tools) as unknown as Record<string, import('../../types/messages.js').ToolStatus>;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
async hasActiveWindow(sessionId: string): Promise<boolean> {
|
||
const windows = await tmuxManager.listWindows();
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) return this._windowExists(session.windowId, windows);
|
||
|
||
// Check if a tmux window exists for this session
|
||
return !!(await this._findWindowForSession(sessionId, windows));
|
||
}
|
||
|
||
// === Permission Mode ===
|
||
|
||
setPermissionMode(sessionId: string, mode: string): boolean {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return false;
|
||
session.permissionMode = mode;
|
||
return true;
|
||
}
|
||
|
||
async switchPermissionMode(sessionId: string, targetMode: string): Promise<boolean> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return false;
|
||
|
||
const currentMode = session.permissionMode || 'default';
|
||
if (currentMode === targetMode) return true;
|
||
|
||
const currentIdx = MODE_CYCLE.indexOf(currentMode);
|
||
const targetIdx = MODE_CYCLE.indexOf(targetMode);
|
||
if (currentIdx < 0 || targetIdx < 0) return false;
|
||
|
||
const presses = (targetIdx - currentIdx + MODE_CYCLE.length) % MODE_CYCLE.length;
|
||
|
||
// Set target BEFORE sending keys — prevents syncPermissionMode
|
||
// from overwriting with intermediate modes during the Shift+Tab transition
|
||
session.permissionMode = targetMode;
|
||
session._modeTransitionDeadline = Date.now() + presses * 200 + 500;
|
||
|
||
for (let i = 0; i < presses; i++) {
|
||
await tmuxManager.sendControl(session.windowId, 'BTab');
|
||
await new Promise<void>(r => setTimeout(r, 150));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// Permission mode precedence (highest → lowest):
|
||
// 1. switchPermissionMode() — user-initiated from ClawTap UI, sets target immediately
|
||
// 2. syncPermissionMode() — CLI reports its mode via hook body (authoritative)
|
||
// 3. Client localStorage — persists user preference across sessions
|
||
|
||
/**
|
||
* Sync permission mode from CLI hook body. Called by hook handlers
|
||
* (via _resolveAndTouch) and by statusline handler to catch desktop
|
||
* Shift+Tab changes that don't trigger tool-use hooks.
|
||
*/
|
||
syncPermissionMode(sessionId: string, body: HookBody): void {
|
||
if (!body.permission_mode) return;
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
// Skip sync while ClawTap-initiated Shift+Tab mode transition is in flight
|
||
if (session._modeTransitionDeadline && Date.now() < session._modeTransitionDeadline) return;
|
||
const cliMode = body.permission_mode === 'dontAsk' ? 'bypassPermissions' : body.permission_mode;
|
||
if (session.permissionMode !== cliMode) {
|
||
session.permissionMode = cliMode;
|
||
this.emit('mode-changed', sessionId, cliMode);
|
||
}
|
||
}
|
||
|
||
// === Hook Handlers (called from Express endpoints) ===
|
||
//
|
||
// Common preamble extracted into _resolveAndTouch():
|
||
// resolve session from body.session_id → syncPermissionMode → update lastActivity
|
||
// handleSessionEnd bypasses the helper (needs different teardown logic).
|
||
|
||
/**
|
||
* Resolve hook body to internal session, sync permission mode, touch lastActivity.
|
||
* Returns { sessionId, session } or null if session cannot be resolved.
|
||
*/
|
||
private _resolveAndTouch(body: HookBody): ResolvedContext | null {
|
||
const sessionId = body.session_id;
|
||
if (!sessionId || !this.sessions.has(sessionId)) return null;
|
||
this.syncPermissionMode(sessionId, body);
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) session.lastActivity = Date.now();
|
||
return { sessionId, session };
|
||
}
|
||
|
||
/** Shared by handlePostToolUse and handlePostToolUseFailure. */
|
||
private _emitToolDone(sessionId: string, body: HookBody, result: unknown): void {
|
||
this.emit('tool-done', sessionId, {
|
||
toolId: body.tool_use_id,
|
||
toolName: body.tool_name,
|
||
input: body.tool_input,
|
||
result,
|
||
});
|
||
this._permissions.dismissAll(sessionId);
|
||
}
|
||
|
||
/** Shared by handleStop and handleStopFailure. */
|
||
private _endTurn(sessionId: string): void {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
session.isProcessing = false;
|
||
if (session.monitor) {
|
||
session.monitor.stop();
|
||
session.monitor = null;
|
||
}
|
||
}
|
||
this.emit('session-idle', sessionId);
|
||
this._permissions.dismissAll(sessionId);
|
||
}
|
||
|
||
async handlePreToolUse(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
|
||
// AskUserQuestion: emit for Mobile picker UI. CLI shows terminal prompt,
|
||
// mobile answers via tmux send-keys.
|
||
if (body.tool_name === 'AskUserQuestion') {
|
||
const requestId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
this._permissions.addQuestion(requestId, ctx.sessionId, { originalInput: body.tool_input || {} });
|
||
this.emit('ask-question', ctx.sessionId, {
|
||
requestId,
|
||
toolName: 'AskUserQuestion',
|
||
input: body.tool_input,
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.emit('tool-start', ctx.sessionId, {
|
||
toolId: body.tool_use_id,
|
||
toolName: body.tool_name,
|
||
input: body.tool_input,
|
||
});
|
||
}
|
||
|
||
async handlePostToolUse(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this._emitToolDone(ctx.sessionId, body, body.tool_response);
|
||
}
|
||
|
||
async handlePostToolUseFailure(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this._emitToolDone(ctx.sessionId, body, {
|
||
content: body.error, is_error: true, is_interrupt: body.is_interrupt,
|
||
});
|
||
}
|
||
|
||
async handleUserPromptSubmit(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
|
||
const { sessionId, session } = ctx;
|
||
if (session) {
|
||
session.isProcessing = true;
|
||
this.emit('processing-started', sessionId);
|
||
// Do NOT markCurrentPosition() here — other mobile clients need to see the user message via JSONL.
|
||
// The sender deduplicates via senderClientId on the client side.
|
||
if (!session.monitor) this._startMonitor(sessionId, session.windowId);
|
||
}
|
||
|
||
this._detectNonInteractive(sessionId);
|
||
}
|
||
|
||
async handleStop(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this._endTurn(ctx.sessionId);
|
||
}
|
||
|
||
async handleStopFailure(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this.emit('session-error', ctx.sessionId, {
|
||
errorType: body.error,
|
||
errorDetails: body.error_details,
|
||
});
|
||
this._endTurn(ctx.sessionId);
|
||
}
|
||
|
||
async handleSessionEnd(body: HookBody): Promise<void> {
|
||
const sessionId = body.session_id;
|
||
if (!sessionId) return;
|
||
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
this._teardownSession(session);
|
||
this.sessions.delete(sessionId);
|
||
}
|
||
|
||
this.emit('session-ended', sessionId);
|
||
}
|
||
|
||
async handlePreCompact(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this.emit('compacting', ctx.sessionId);
|
||
}
|
||
|
||
async handlePostCompact(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
this.emit('compact-done', ctx.sessionId);
|
||
}
|
||
|
||
/** Handle real-time session discovery when CLI starts (SessionStart hook). */
|
||
async handleSessionStart(body: HookBody): Promise<void> {
|
||
const cliUuid = body.session_id;
|
||
if (!cliUuid) return;
|
||
|
||
if (this.sessions.has(cliUuid)) {
|
||
this.sessions.get(cliUuid)!.lastActivity = Date.now();
|
||
return;
|
||
}
|
||
// Unknown UUID — not our session, ignore
|
||
}
|
||
|
||
/**
|
||
* Fire-and-forget notification — no return value.
|
||
* YOLO/Auto-edit: CLI handles auto-allow via Shift+Tab, skip mobile overlay.
|
||
* Normal: emit permission-request for mobile overlay. User answers via
|
||
* tmux send-keys ('y'/'n'), not via hook response.
|
||
*/
|
||
async handlePermissionRequest(body: HookBody): Promise<void> {
|
||
const ctx = this._resolveAndTouch(body);
|
||
if (!ctx) return;
|
||
const { sessionId, session } = ctx;
|
||
const mode = session?.permissionMode || 'default';
|
||
|
||
// YOLO/Auto-edit: CLI already auto-allows via Shift+Tab — skip mobile overlay
|
||
if (mode === 'bypassPermissions') return;
|
||
if (mode === 'acceptEdits' && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(body.tool_name!)) return;
|
||
// Plan tools have their own approval UI (PlanMode card) — skip generic overlay.
|
||
// AskUserQuestion is handled by PreToolUse (question overlay, not permission overlay).
|
||
if (['ExitPlanMode', 'EnterPlanMode', 'AskUserQuestion'].includes(body.tool_name!)) return;
|
||
|
||
// Normal mode: notify mobile to show permission overlay
|
||
const requestId = crypto.randomUUID();
|
||
// Store truncated input for reconnect replay — full payload already broadcast via emit below
|
||
const inputSummary: Record<string, unknown> = body.tool_input ? Object.fromEntries(
|
||
Object.entries(body.tool_input).map(([k, v]) => [k, typeof v === 'string' && v.length > 500 ? v.substring(0, 500) + '\u2026' : v])
|
||
) : {};
|
||
this._permissions.addPermission(requestId, sessionId, { toolName: body.tool_name!, input: inputSummary });
|
||
this.emit('permission-request', sessionId, {
|
||
requestId,
|
||
toolName: body.tool_name,
|
||
input: body.tool_input,
|
||
});
|
||
}
|
||
|
||
async respondPermission(requestId: string, behavior: PermissionBehavior): Promise<void> {
|
||
const pending = this._permissions.resolvePermission(requestId);
|
||
if (!pending) return;
|
||
|
||
const session = this.sessions.get(pending.sessionId);
|
||
if (!session) return;
|
||
|
||
const optionIndex = behavior === 'allow' ? 0
|
||
: behavior === 'allow_session' ? 1
|
||
: TmuxAdapter.PERMISSION_DENY_INDEX;
|
||
await this._selectOption(session.windowId, optionIndex);
|
||
}
|
||
|
||
/**
|
||
* Release all pending requests for a session (e.g., when Mobile disconnects).
|
||
* Just clears pending state — CLI prompt remains on terminal.
|
||
*/
|
||
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) {
|
||
this._selectOption(session.windowId, 0).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async respondQuestion(requestId: string, answer: string): Promise<void> {
|
||
const pending = this._permissions.resolveQuestion(requestId);
|
||
if (!pending) return;
|
||
|
||
const input = pending.originalInput || {};
|
||
const questions = (input.questions as Array<{ options?: Array<{ label?: string; value?: string }> }>) || [];
|
||
const options = questions[0]?.options || [];
|
||
const optionIndex = options.findIndex(o => o.label === answer || o.value === answer);
|
||
|
||
const session = this.sessions.get(pending.sessionId);
|
||
if (!session) return;
|
||
|
||
if (optionIndex >= 0) {
|
||
// Matched a predefined option — select it directly
|
||
await this._selectOption(session.windowId, optionIndex);
|
||
} else {
|
||
// Free-form answer — select "Type something" (at index options.length) then type answer
|
||
await this._selectOption(session.windowId, options.length);
|
||
await new Promise<void>(r => setTimeout(r, 200));
|
||
await tmuxManager.sendKeys(session.windowId, answer, true);
|
||
}
|
||
}
|
||
|
||
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
|
||
if (textValue != null) {
|
||
this.respondQuestion(requestId, textValue);
|
||
} else if (selectedOption != null) {
|
||
// Permission behaviors are named ('allow', 'allow_session', 'deny')
|
||
// Question options are numeric indices ('0', '1', '2')
|
||
const isPermission = ['allow', 'allow_session', 'deny'].includes(selectedOption);
|
||
if (isPermission) {
|
||
this.respondPermission(requestId, selectedOption as any);
|
||
} else {
|
||
// Numeric index — validate before consuming the pending entry
|
||
const index = parseInt(selectedOption);
|
||
if (isNaN(index)) return;
|
||
const pending = this._permissions.resolveQuestion(requestId);
|
||
if (!pending) return;
|
||
const session = this.sessions.get(pending.sessionId);
|
||
if (!session) return;
|
||
this._selectOption(session.windowId, index).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Respond to the CLI's plan approval selector.
|
||
* Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback
|
||
*/
|
||
async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session || optionIndex < 0 || optionIndex > PLAN_OPTION.TEXT_FEEDBACK) return;
|
||
if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK && text) {
|
||
await this._selectOption(session.windowId, PLAN_OPTION.TEXT_FEEDBACK);
|
||
await new Promise<void>(r => setTimeout(r, 200));
|
||
await tmuxManager.sendKeys(session.windowId, text, true);
|
||
} else {
|
||
await this._selectOption(session.windowId, optionIndex);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Navigate a CLI interactive selector by pressing Down `index` times, then Enter.
|
||
* Cursor starts on option 0 (first item), so index=0 just presses Enter.
|
||
*/
|
||
private async _selectOption(windowId: string, index: number): Promise<void> {
|
||
for (let i = 0; i < index; i++) {
|
||
await tmuxManager.sendControl(windowId, 'Down');
|
||
await new Promise<void>(r => setTimeout(r, 100));
|
||
}
|
||
await tmuxManager.sendControl(windowId, 'Enter');
|
||
}
|
||
|
||
getActiveSessions(): ActiveSessionInfo[] {
|
||
const sessions: ActiveSessionInfo[] = [];
|
||
for (const [sessionId, session] of this.sessions) {
|
||
sessions.push({
|
||
sessionId,
|
||
cwd: session.cwd,
|
||
adapter: 'claude',
|
||
permissionMode: session.permissionMode,
|
||
lastActivity: session.lastActivity || null,
|
||
hasClients: false,
|
||
hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120000)),
|
||
isNonInteractive: session.isNonInteractive || false,
|
||
firstPrompt: session.firstPrompt || null,
|
||
});
|
||
}
|
||
return sessions;
|
||
}
|
||
|
||
isProcessing(sessionId: string): boolean {
|
||
const session = this.sessions.get(sessionId);
|
||
return !!(session?.isProcessing);
|
||
}
|
||
|
||
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 (!liveWindowIds.has(session.windowId)) {
|
||
console.log(`[tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
|
||
this._teardownSession(session);
|
||
this.sessions.delete(sessionId);
|
||
this.emit('session-ended', sessionId);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}, 60000);
|
||
// Don't keep the process alive just for cleanup — allows hooks-cli
|
||
// and other short-lived consumers to exit naturally after their work.
|
||
this._cleanupInterval.unref();
|
||
}
|
||
|
||
// === Helpers ===
|
||
|
||
private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState {
|
||
return {
|
||
windowId,
|
||
monitor: null,
|
||
watcher: null,
|
||
parser: null,
|
||
cwd,
|
||
cliSessionId,
|
||
permissionMode,
|
||
lastActivity: Date.now(),
|
||
firstPrompt: null,
|
||
isProcessing: false,
|
||
isNonInteractive: false,
|
||
_interactiveChecked: false,
|
||
_promptSenderClientId: null,
|
||
_modeTransitionDeadline: 0,
|
||
_watcherPending: false,
|
||
};
|
||
}
|
||
|
||
private _teardownSession(session: SessionState): void {
|
||
if (session.monitor) { session.monitor.stop(); session.monitor = null; }
|
||
if (session.watcher) { session.watcher.stop(); session.watcher = null; session.parser = null; }
|
||
}
|
||
|
||
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 ===
|
||
|
||
private _startMonitor(sessionId: string, windowId: string): void {
|
||
const monitor = new PaneMonitor(windowId);
|
||
monitor.onThinking((thinking) => {
|
||
this.emit('thinking', sessionId, thinking);
|
||
});
|
||
monitor.onStreamingText((text) => {
|
||
this.emit('streaming-text', sessionId, text);
|
||
});
|
||
monitor.start();
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) session.monitor = monitor;
|
||
}
|
||
|
||
private async _ensureWatcher(sessionId: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session || session.watcher || session._watcherPending) return;
|
||
session._watcherPending = true;
|
||
|
||
const cliId = sessionId;
|
||
|
||
// Construct path directly (we know the UUID and cwd)
|
||
let jsonlPath: string | null = null;
|
||
if (session.cwd && cliId) {
|
||
const encoded = encodeDirName(session.cwd);
|
||
const directPath = join(PROJECTS_DIR, encoded, `${cliId}.jsonl`);
|
||
// Wait for file to appear (Claude creates it on first write)
|
||
// First 25 iterations at 200ms (5s), then 1s intervals for remaining time
|
||
for (let i = 0; i < 50; i++) {
|
||
try {
|
||
await stat(directPath);
|
||
jsonlPath = directPath;
|
||
break;
|
||
} catch {
|
||
await new Promise<void>(r => setTimeout(r, i < 25 ? 200 : 1000));
|
||
}
|
||
}
|
||
}
|
||
// Fallback: search all project dirs
|
||
if (!jsonlPath) jsonlPath = await this._findJsonlPath(cliId);
|
||
if (!jsonlPath) {
|
||
session._watcherPending = false; // Allow retry
|
||
return;
|
||
}
|
||
|
||
const parser = new TranscriptParser();
|
||
const watcher = new JsonlWatcher(jsonlPath);
|
||
|
||
watcher.onNewEntries((entries) => {
|
||
const { messages, interrupted } = parser.parse(entries as JsonlEntry[]);
|
||
if (messages.length > 0) {
|
||
// Capture first user prompt for active sessions list
|
||
if (!session.firstPrompt) {
|
||
const userMsg = messages.find(m => m.role === 'user');
|
||
if (userMsg) session.firstPrompt = (extractText(userMsg.content) || '').substring(0, 200);
|
||
}
|
||
|
||
// Tag user messages with sender's client ID so only the sender skips (dedup)
|
||
for (const msg of messages) {
|
||
if (msg.role === 'user' && session._promptSenderClientId) {
|
||
msg.senderClientId = session._promptSenderClientId;
|
||
session._promptSenderClientId = null;
|
||
}
|
||
}
|
||
|
||
this.emit('new-messages', sessionId, messages);
|
||
}
|
||
if (interrupted) {
|
||
this.emit('session-idle', sessionId);
|
||
}
|
||
const tools = parser.getPendingTools();
|
||
if (tools.size > 0) {
|
||
this.emit('tool-updates', sessionId, Object.fromEntries(tools));
|
||
}
|
||
});
|
||
|
||
watcher.start({ skipExisting: true });
|
||
session.watcher = watcher;
|
||
session.parser = parser;
|
||
session._watcherPending = false;
|
||
|
||
// Backfill firstPrompt from JSONL header (handles race where watcher
|
||
// starts after first user message was already written)
|
||
if (!session.firstPrompt && jsonlPath) {
|
||
try {
|
||
const { firstPrompt } = await parseSessionHeader(jsonlPath, sessionId);
|
||
if (firstPrompt) session.firstPrompt = firstPrompt;
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
private async _findJsonlPath(sessionId: string): Promise<string | null> {
|
||
try {
|
||
const dirs = await readdir(PROJECTS_DIR);
|
||
for (const dir of dirs) {
|
||
const filePath = join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
|
||
try {
|
||
await stat(filePath);
|
||
return filePath;
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
private async _findWindowForSession(sessionId: string, windowList?: TmuxWindow[]): Promise<string | null> {
|
||
const windows = windowList || await tmuxManager.listWindows();
|
||
// Search tmux windows by sessionId (window name = CLI UUID)
|
||
const match = windows.find(w => w.name === sessionId);
|
||
return match?.id || null;
|
||
}
|
||
|
||
private async _detectNonInteractive(sessionId: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session || session._interactiveChecked) return;
|
||
session._interactiveChecked = true;
|
||
|
||
try {
|
||
const content = await tmuxManager.capturePane(session.windowId);
|
||
if (content.includes('claude -p ') || content.includes('claude --print')) {
|
||
session.isNonInteractive = true;
|
||
console.log(`[tmux] Session ${sessionId} detected as non-interactive (claude -p)`);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
private async _windowExists(windowId: string, windowList?: TmuxWindow[]): Promise<boolean> {
|
||
const windows = windowList || await tmuxManager.listWindows();
|
||
return windows.some(w => w.id === windowId);
|
||
}
|
||
|
||
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');
|
||
const hasPrompt = lines.some(l => /^\s*❯/.test(l));
|
||
const lineCount = lines.filter(l => l.trim()).length;
|
||
if (attempt <= 3 || attempt % 5 === 0) {
|
||
console.log(`[adapter] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
|
||
}
|
||
// Auto-accept bypass permissions confirmation prompt (Claude v2.1.85+).
|
||
// Detect by structure (numbered selection list) + context (bypass permissions).
|
||
const isSelectionPrompt = /❯\s+\d+\./.test(content);
|
||
const isBypassPrompt = /[Bb]ypass\s+[Pp]ermissions/.test(content);
|
||
if (isSelectionPrompt && isBypassPrompt) {
|
||
const acceptMatch = content.match(/(\d+)\.\s+Yes/);
|
||
const acceptOption = acceptMatch ? parseInt(acceptMatch[1]) : 2;
|
||
console.log(`[adapter] Bypass permissions prompt detected, selecting option ${acceptOption}`);
|
||
for (let i = 1; i < acceptOption; i++) {
|
||
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;
|
||
}
|
||
|
||
if (hasPrompt && lineCount >= 3) {
|
||
console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`);
|
||
await new Promise<void>(r => setTimeout(r, 300));
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.log(`[adapter] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
|
||
}
|
||
await new Promise<void>(r => setTimeout(r, 1000));
|
||
}
|
||
console.warn(`[adapter] CLI ready timeout for ${windowId} after ${attempt} attempts`);
|
||
}
|
||
}
|
||
|
||
export const tmuxAdapter = new TmuxAdapter();
|